From 26dbb9b486c7504314fad7db05fb0715baed2177 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 26 May 2026 08:49:22 +1000 Subject: [PATCH 01/13] Remove --resources from aspire ps (#17472) Resource snapshot streaming from 'aspire ps --resources' overlapped heavily with 'aspire describe --follow --apphost ' and emitted the full resource set per AppHost on every change. Drop --resources (and --include-hidden, which only filtered the resource payload) so ps stays an AppHost-level summary similar to 'docker ps'. Consumers that need resource data should use 'aspire describe' instead. - Remove the --resources/--include-hidden options, AppHostDisplayInfo.Resources and the resource fetch/follow watcher plumbing from PsCommand. - Drop ResourcesOptionDescription from PsCommandStrings and reword FollowOptionDescription; regenerate xlf for all locales. - Drop --resources tests from PsCommandTests; remaining 28 tests pass. - Update docs/specs/cli-output-formats.md to remove the --resources example and point readers at 'aspire describe' for resource streaming. - VS Code extension: drop the _supportsResources branch in AppHostDataRepository (and its --resources fallback path), and update the matching tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/specs/cli-output-formats.md | 29 +- .../src/test/appHostDataRepository.test.ts | 27 +- extension/src/views/AppHostDataRepository.ts | 24 +- src/Aspire.Cli/Commands/PsCommand.cs | 138 +---- .../Resources/PsCommandStrings.Designer.cs | 6 - .../Resources/PsCommandStrings.resx | 5 +- .../Resources/xlf/PsCommandStrings.cs.xlf | 9 +- .../Resources/xlf/PsCommandStrings.de.xlf | 9 +- .../Resources/xlf/PsCommandStrings.es.xlf | 9 +- .../Resources/xlf/PsCommandStrings.fr.xlf | 9 +- .../Resources/xlf/PsCommandStrings.it.xlf | 9 +- .../Resources/xlf/PsCommandStrings.ja.xlf | 9 +- .../Resources/xlf/PsCommandStrings.ko.xlf | 9 +- .../Resources/xlf/PsCommandStrings.pl.xlf | 9 +- .../Resources/xlf/PsCommandStrings.pt-BR.xlf | 9 +- .../Resources/xlf/PsCommandStrings.ru.xlf | 9 +- .../Resources/xlf/PsCommandStrings.tr.xlf | 9 +- .../xlf/PsCommandStrings.zh-Hans.xlf | 9 +- .../xlf/PsCommandStrings.zh-Hant.xlf | 9 +- .../Commands/PsCommandTests.cs | 478 +----------------- 20 files changed, 50 insertions(+), 774 deletions(-) diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index bbe2393303a..1872557e34c 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -95,32 +95,7 @@ If discovery finds no AppHost candidates, the stream emits no lines. The stream ] ``` -Use `aspire ps --format json --resources` to include each AppHost's current resources: - -```json -[ - { - "appHostPath": "/path/to/MyApp.AppHost/MyApp.AppHost.csproj", - "appHostPid": 12345, - "status": "running", - "resources": [ - { - "name": "api", - "displayName": "api", - "resourceType": "Project", - "state": "Running", - "healthStatus": "Healthy", - "urls": [ - { - "name": "https", - "url": "https://localhost:5001" - } - ] - } - ] - } -] -``` +`aspire ps` returns only AppHost-level information. Use [`aspire describe`](#aspire-describe) to inspect or stream the resources that belong to an AppHost. `aspire ps --follow --format json` streams newline-delimited AppHost objects. New or changed AppHosts are emitted with `"status": "running"`. When an AppHost stops, it is emitted one last time with `"status": "stopped"` so consumers can remove it from their state: @@ -172,7 +147,7 @@ Use `aspire ps --format json --resources` to include each AppHost's current reso #### Resource fields -`aspire describe`, `aspire describe --follow`, and `aspire ps --resources` share the resource object shape: +`aspire describe` and `aspire describe --follow` share the resource object shape: | Field | Description | | ----- | ----------- | diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index b53d9bf384d..00d98759286 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -271,7 +271,7 @@ suite('AppHostDataRepository', () => { let getAppHostsLineCallback: ((line: string) => void) | undefined; const getAppHostsProcess = new TestChildProcess(); const describeProcess = new TestChildProcess(); - const psResourcesProcess = new TestChildProcess(); + const psFollowProcess = new TestChildProcess(); const psFallbackProcess = new TestChildProcess(); const replacementDescribeProcess = new TestChildProcess(); const psSuccessProcess = new TestChildProcess(); @@ -280,7 +280,7 @@ suite('AppHostDataRepository', () => { return getAppHostsProcess; }); spawnStub.onSecondCall().returns(describeProcess); - spawnStub.onThirdCall().returns(psResourcesProcess); + spawnStub.onThirdCall().returns(psFollowProcess); spawnStub.onCall(3).returns(psFallbackProcess); spawnStub.onCall(4).returns(replacementDescribeProcess); spawnStub.onCall(5).returns(psSuccessProcess); @@ -309,12 +309,7 @@ suite('AppHostDataRepository', () => { psFollowOptions.exitCallback(1); await waitForAppHostDiscovery(); - const psResourcesOptions = spawnStub.getCall(3).args[3]; - psResourcesOptions.stderrCallback('resources unavailable'); - psResourcesOptions.exitCallback(1); - await waitForAppHostDiscovery(); - - const psFallbackOptions = spawnStub.getCall(4).args[3]; + const psFallbackOptions = spawnStub.getCall(3).args[3]; psFallbackOptions.stderrCallback('ps failed'); psFallbackOptions.exitCallback(1); assert.ok(repository.errorMessage?.includes('ps failed'), repository.errorMessage); @@ -372,7 +367,7 @@ suite('AppHostDataRepository', () => { assert.strictEqual(repository.viewMode, 'workspace'); assert.strictEqual(spawnStub.callCount, 2); - assert.deepStrictEqual(spawnStub.secondCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.secondCall.args[2], ['ps', '--follow', '--format', 'json']); } finally { repository.dispose(); workspaceFoldersStub.restore(); @@ -418,7 +413,7 @@ suite('AppHostDataRepository', () => { assert.strictEqual(describeProcess.killed, false); assert.strictEqual(spawnStub.callCount, 3); assert.deepStrictEqual(spawnStub.secondCall.args[2], ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']); - assert.deepStrictEqual(spawnStub.thirdCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.thirdCall.args[2], ['ps', '--follow', '--format', 'json']); } finally { repository.dispose(); workspaceFoldersStub.restore(); @@ -678,7 +673,7 @@ suite('AppHostDataRepository', () => { await waitForMicrotasks(); assert.ok(psOptions); - assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json']); psOptions.lineCallback(JSON.stringify([{ appHostPath: '/workspace/apphost/apphost.cs', appHostPid: 125881, @@ -1001,7 +996,7 @@ suite('AppHostDataRepository global polling', () => { repository.setPanelVisible(true); await waitForMicrotasks(); - assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json']); repository.setPanelVisible(false); @@ -1020,7 +1015,7 @@ suite('AppHostDataRepository global polling', () => { repository.setPanelVisible(true); await waitForMicrotasks(); - assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json']); const lineCallback = spawnStub.firstCall.args[3].lineCallback; lineCallback(JSON.stringify({ @@ -1096,7 +1091,7 @@ suite('AppHostDataRepository global polling', () => { repository.dispose(); }); - test('cli path failure does not disable resources polling', async () => { + test('cli path failure does not disable ps polling', async () => { const clock = sinon.useFakeTimers(); getCliPathStub.onFirstCall().rejects(new Error('CLI path unavailable')); getCliPathStub.onSecondCall().resolves('aspire'); @@ -1114,7 +1109,7 @@ suite('AppHostDataRepository global polling', () => { await waitForMicrotasks(); assert.strictEqual(spawnStub.calledOnce, true); - assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--format', 'json']); } finally { repository.dispose(); clock.restore(); @@ -1146,7 +1141,7 @@ suite('AppHostDataRepository global polling', () => { } }); - test('stopped ps does not start fallback after resources failure', async () => { + test('stopped ps does not start fallback after exit', async () => { const childProcess = new TestChildProcess(); spawnStub.returns(childProcess); const repository = new AppHostDataRepository(terminalProvider); diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 53a76643e8e..1feabe2e1d1 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -120,7 +120,6 @@ export class AppHostDataRepository { private _pollingInterval: ReturnType | undefined; private _psProcesses = new Set(); private _psFetchVersion = 0; - private _supportsResources = true; private _supportsPsFollow = true; private _fetchInProgress = false; @@ -702,9 +701,6 @@ export class AppHostDataRepository { }; const args = ['ps', '--follow', '--format', 'json']; - if (this._supportsResources) { - args.push('--resources'); - } psProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { noExtensionVariables: true, @@ -769,34 +765,16 @@ export class AppHostDataRepository { const fetchVersion = ++this._psFetchVersion; const args = ['ps', '--format', 'json']; - if (this._supportsResources) { - args.push('--resources'); - } this._runPsCommand(args, fetchVersion, (code, stdout, stderr) => { if (code === 0) { this._setPsError(undefined); this._handlePsOutput(stdout); - this._fetchInProgress = false; - } else if (this._supportsResources) { - this._supportsResources = false; - extensionLogOutputChannel.info('aspire ps --resources failed, falling back to aspire ps without --resources'); - this._runPsCommand(['ps', '--format', 'json'], fetchVersion, (retryCode, retryStdout, retryStderr) => { - if (retryCode === 0) { - this._setPsError(undefined); - this._handlePsOutput(retryStdout); - } else { - this._loadingGlobal = false; - this._updateLoadingContext(); - this._setPsError(errorFetchingAppHosts(retryStderr || `exit code ${retryCode}`)); - } - this._fetchInProgress = false; - }); } else { this._loadingGlobal = false; this._updateLoadingContext(); this._setPsError(errorFetchingAppHosts(stderr || `exit code ${code}`)); - this._fetchInProgress = false; } + this._fetchInProgress = false; }); } diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index d36e128e336..76082600915 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -4,7 +4,6 @@ using System.CommandLine; using System.Globalization; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading.Channels; using Aspire.Cli.Backchannel; @@ -13,7 +12,6 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; -using Aspire.Shared.Model.Serialization; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -35,9 +33,6 @@ internal sealed class AppHostDisplayInfo [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? LogFilePath { get; init; } - - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? Resources { get; set; } } internal static class AppHostDisplayStatus @@ -48,18 +43,6 @@ internal static class AppHostDisplayStatus [JsonSerializable(typeof(List))] [JsonSerializable(typeof(AppHostDisplayInfo))] -[JsonSerializable(typeof(ResourceJson))] -[JsonSerializable(typeof(ResourceUrlJson))] -[JsonSerializable(typeof(ResourceVolumeJson))] -[JsonSerializable(typeof(ResourceRelationshipJson))] -[JsonSerializable(typeof(ResourceHealthReportJson))] -[JsonSerializable(typeof(ResourceCommandJson))] -[JsonSerializable(typeof(ResourceCommandArgumentJson[]))] -[JsonSerializable(typeof(JsonNode))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] [JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] internal sealed partial class PsCommandJsonContext : JsonSerializerContext { @@ -98,16 +81,6 @@ internal sealed partial class PsCommand : BaseCommand Description = PsCommandStrings.JsonOptionDescription }; - private static readonly Option s_resourcesOption = new("--resources") - { - Description = PsCommandStrings.ResourcesOptionDescription - }; - - private static readonly Option s_includeHiddenOption = new("--include-hidden") - { - Description = SharedCommandStrings.IncludeHiddenOptionDescription - }; - private static readonly Option s_followOption = new("--follow", "-f") { Description = PsCommandStrings.FollowOptionDescription @@ -128,8 +101,6 @@ public PsCommand( _logger = logger; Options.Add(s_formatOption); - Options.Add(s_resourcesOption); - Options.Add(s_includeHiddenOption); Options.Add(s_followOption); } @@ -138,12 +109,10 @@ protected override async Task ExecuteAsync(ParseResult parseResul using var activity = Telemetry.StartDiagnosticActivity(Name); var format = parseResult.GetValue(s_formatOption); - var includeResources = parseResult.GetValue(s_resourcesOption); - var includeHidden = parseResult.GetValue(s_includeHiddenOption); if (parseResult.GetValue(s_followOption)) { - return await ExecuteFollowAsync(format, includeResources, includeHidden, cancellationToken).ConfigureAwait(false); + return await ExecuteFollowAsync(format, cancellationToken).ConfigureAwait(false); } // Scan for running AppHosts (same as ListAppHostsTool). JSON output must not go @@ -174,7 +143,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul .ToList(); // Gather info for each AppHost - var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, includeResources && format == OutputFormat.Json, includeHidden, cancellationToken).ConfigureAwait(false); + var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, cancellationToken).ConfigureAwait(false); if (format == OutputFormat.Json) { @@ -201,9 +170,7 @@ private abstract record PsFollowUpdate; private sealed record ConnectionsUpdate(IReadOnlyList Connections) : PsFollowUpdate; - private sealed record ResourceUpdate(IAppHostAuxiliaryBackchannel Connection) : PsFollowUpdate; - - private async Task ExecuteFollowAsync(OutputFormat format, bool includeResources, bool includeHidden, CancellationToken cancellationToken) + private async Task ExecuteFollowAsync(OutputFormat format, CancellationToken cancellationToken) { if (format != OutputFormat.Json) { @@ -215,12 +182,9 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i { SingleReader = true }); - var currentConnections = new List(); var appHostKeyComparer = GetAppHostKeyComparer(); var activeAppHosts = new Dictionary(appHostKeyComparer); var lastJsonByAppHost = new Dictionary(appHostKeyComparer); - CancellationTokenSource? resourceWatchCts = null; - List resourceWatchTasks = []; _ = Task.Run(async () => { @@ -251,10 +215,8 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i { if (update is ConnectionsUpdate connectionsUpdate) { - currentConnections = OrderConnections(connectionsUpdate.Connections); - await RestartResourceWatchersAsync().ConfigureAwait(false); - - var currentAppHosts = await GatherAppHostInfosAsync(currentConnections, includeResources, includeHidden, followCancellationToken).ConfigureAwait(false); + var currentConnections = OrderConnections(connectionsUpdate.Connections); + var currentAppHosts = await GatherAppHostInfosAsync(currentConnections, followCancellationToken).ConfigureAwait(false); var nextActiveAppHosts = new Dictionary(appHostKeyComparer); foreach (var appHost in currentAppHosts) @@ -277,27 +239,6 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i activeAppHosts = nextActiveAppHosts; } - else if (update is ResourceUpdate resourceUpdate) - { - var appHostInfos = await GatherAppHostInfosAsync([resourceUpdate.Connection], includeResources, includeHidden, followCancellationToken).ConfigureAwait(false); - var appHost = appHostInfos.SingleOrDefault(); - if (appHost is null) - { - continue; - } - - var key = GetAppHostKey(appHost); - if (!activeAppHosts.ContainsKey(key)) - { - continue; - } - - activeAppHosts[key] = appHost; - if (!await TryWriteAppHostInfoAsync(appHost).ConfigureAwait(false)) - { - return CommandResult.Success(); - } - } } } catch (OperationCanceledException) when (followCancellationToken.IsCancellationRequested) @@ -312,57 +253,10 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i finally { await followCancellationTokenSource.CancelAsync().ConfigureAwait(false); - if (resourceWatchCts is not null) - { - await resourceWatchCts.CancelAsync().ConfigureAwait(false); - await Task.WhenAll(resourceWatchTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - resourceWatchCts.Dispose(); - } } return CommandResult.Success(); - async Task RestartResourceWatchersAsync() - { - if (resourceWatchCts is not null) - { - await resourceWatchCts.CancelAsync().ConfigureAwait(false); - await Task.WhenAll(resourceWatchTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - resourceWatchCts.Dispose(); - resourceWatchCts = null; - resourceWatchTasks = []; - } - - if (!includeResources || format != OutputFormat.Json) - { - return; - } - - resourceWatchCts = CancellationTokenSource.CreateLinkedTokenSource(followCancellationToken); - var resourceCancellationToken = resourceWatchCts.Token; - foreach (var connection in currentConnections) - { - resourceWatchTasks.Add(Task.Run(async () => - { - try - { - await foreach (var _ in connection.WatchResourceSnapshotsAsync(includeHidden, resourceCancellationToken).WithCancellation(resourceCancellationToken).ConfigureAwait(false)) - { - await updates.Writer.WriteAsync(new ResourceUpdate(connection), resourceCancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) when (resourceCancellationToken.IsCancellationRequested) - { - // Expected when the connection list changes or the command stops. - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed while watching resource snapshots for {AppHostPath}.", connection.AppHostInfo?.AppHostPath); - } - }, CancellationToken.None)); - } - } - async Task TryWriteAppHostInfoAsync(AppHostDisplayInfo appHost) { var key = GetAppHostKey(appHost); @@ -414,8 +308,7 @@ private static AppHostDisplayInfo CopyWithStatus(AppHostDisplayInfo appHost, str SdkVersion = appHost.SdkVersion, CliPid = appHost.CliPid, DashboardUrl = appHost.DashboardUrl, - LogFilePath = appHost.LogFilePath, - Resources = appHost.Resources + LogFilePath = appHost.LogFilePath }; } @@ -426,7 +319,7 @@ private static List OrderConnections(IEnumerable> GatherAppHostInfosAsync(List connections, bool includeResources, bool includeHidden, CancellationToken cancellationToken) + private async Task> GatherAppHostInfosAsync(List connections, CancellationToken cancellationToken) { var appHostInfos = new List(); @@ -480,20 +373,6 @@ private async Task> GatherAppHostInfosAsync(List? resources = null; - if (includeResources) - { - try - { - var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden, cancellationToken).ConfigureAwait(false); - resources = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardUrl, includeEnvironmentVariableValues: false); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to get resource snapshots for {AppHostPath}", info.AppHostPath); - } - } - appHostInfos.Add(new AppHostDisplayInfo { AppHostPath = appHostPath ?? PsCommandStrings.UnknownPath, @@ -502,8 +381,7 @@ private async Task> GatherAppHostInfosAsync(List Unknown - - Include resource details for each running AppHost. Only applies to JSON output. - - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. The --follow option only supports JSON output. Use --format json. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf index 3caecff13d0..d16e817de7d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Výstup ve formátu JSON - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Neznámé diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf index b72fb405eaa..302c834e8f2 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Ausgabe im JSON-Format. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Unbekannt diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf index ce2f85d6b41..0c4df433772 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Salida en formato JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Desconocido diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf index f10c909806d..8aa4be8f43d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Sortie au format JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Inconnu diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf index cec06e8f1c7..10349c21b98 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Output in formato JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Sconosciuto diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf index 398bfc4972b..f59b502a801 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ JSON 形式で出力します。 - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 不明 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf index 0d78643506b..b5eb897baeb 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ JSON 형식의 출력입니다. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 알 수 없음 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf index f078f83fe46..ac3b47aad3e 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Wynik w formacie JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Nieznane diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf index dc6d95aab59..7ca49e6015d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Saída no formato JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Desconhecido diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf index 6a8d805b322..e726bd4836c 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Вывод в формате JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Неизвестно diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf index 613a8aaec81..8e8c7059cc5 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Çıkışı JSON biçiminde oluşturun. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Bilinmiyor diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf index d9bf1dea07b..f63554406d8 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ 以 JSON 格式输出。 - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 未知 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf index 0ce98849920..bda1455aa72 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ 以 JSON 格式輸出。 - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 未知 diff --git a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs index f4fc2c0919a..31ff6ec3e68 100644 --- a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs @@ -3,7 +3,6 @@ using System.Net; using System.Net.Sockets; -using System.Runtime.CompilerServices; using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; @@ -501,7 +500,7 @@ public async Task PsCommand_JsonFormat_DoesNotShowScanningStatus() using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources"); + var result = command.Parse("ps --format json"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -513,311 +512,6 @@ public async Task PsCommand_JsonFormat_DoesNotShowScanningStatus() Assert.Equal(JsonValueKind.Array, document.RootElement.ValueKind); } - [Fact] - public async Task PsCommand_ResourcesOption_IncludesResourcesInJsonOutput() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234, - CliProcessId = 5678 - }, - DashboardUrlsState = new DashboardUrlsState - { - BaseUrlWithLoginToken = "http://localhost:18888/login?t=abc123" - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running", - StateStyle = "success", - Urls = - [ - new ResourceSnapshotUrl { Name = "https", Url = "https://localhost:7001" } - ] - }, - new ResourceSnapshot - { - Name = "redis", - DisplayName = "redis", - ResourceType = "Container", - State = "Running", - StateStyle = "success" - }, - new ResourceSnapshot - { - Name = "aspire-dashboard", - DisplayName = "aspire-dashboard", - ResourceType = "Project", - State = "Hidden", - IsHidden = true - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); - Assert.NotNull(appHosts); - Assert.Single(appHosts); - - var appHost = appHosts[0]; - Assert.NotNull(appHost.Resources); - Assert.Equal(2, appHost.Resources.Count); - - var apiService = appHost.Resources.First(r => r.Name == "apiservice"); - Assert.Equal("Project", apiService.ResourceType); - Assert.Equal("Running", apiService.State); - Assert.NotNull(apiService.Urls); - Assert.Single(apiService.Urls); - Assert.Equal("https://localhost:7001", apiService.Urls[0].Url); - - var redis = appHost.Resources.First(r => r.Name == "redis"); - Assert.Equal("Container", redis.ResourceType); - Assert.Equal("Running", redis.State); - Assert.DoesNotContain(appHost.Resources, resource => resource.Name == "aspire-dashboard"); - } - - [Fact] - public async Task PsCommand_ResourcesOption_WithIncludeHidden_IncludesHiddenResources() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running" - }, - new ResourceSnapshot - { - Name = "aspire-dashboard", - DisplayName = "aspire-dashboard", - ResourceType = "Project", - State = "Hidden", - IsHidden = true - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources --include-hidden"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); - Assert.NotNull(appHosts); - var resources = Assert.Single(appHosts).Resources; - Assert.NotNull(resources); - Assert.Contains(resources, resource => resource.Name == "apiservice"); - Assert.Contains(resources, resource => resource.Name == "aspire-dashboard"); - } - - [Theory] - [InlineData(false, 1)] - [InlineData(true, 2)] - public async Task PsCommand_FollowResourcesOption_HandlesHiddenResources(bool includeHidden, int expectedResourceCount) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - using var cancellationTokenSource = new CancellationTokenSource(); - var outputLines = new List(); - var textWriter = new TestOutputTextWriter(outputHelper, line => - { - outputLines.Add(line); - cancellationTokenSource.Cancel(); - }); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running" - }, - new ResourceSnapshot - { - Name = "aspire-dashboard", - DisplayName = "aspire-dashboard", - ResourceType = "Project", - State = "Hidden", - IsHidden = true - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var includeHiddenArg = includeHidden ? " --include-hidden" : string.Empty; - var result = command.Parse($"ps --format json --resources --follow{includeHiddenArg}"); - - var exitCode = await result.InvokeAsync(cancellationToken: cancellationTokenSource.Token).DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - var outputLine = Assert.Single(outputLines); - var appHost = JsonSerializer.Deserialize(outputLine, PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo); - Assert.NotNull(appHost); - Assert.Equal(AppHostDisplayStatus.Running, appHost.Status); - var resources = appHost.Resources; - Assert.NotNull(resources); - Assert.Equal(expectedResourceCount, resources.Count); - Assert.Contains(resources, resource => resource.Name == "apiservice"); - Assert.Equal(includeHidden, resources.Any(resource => resource.Name == "aspire-dashboard")); - } - - [Fact] - public async Task PsCommand_FollowJsonFormat_StreamsChangedAppHostForResourceUpdates() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - using var cancellationTokenSource = new CancellationTokenSource(); - var outputLines = new List(); - var updateResource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var textWriter = new TestOutputTextWriter(outputHelper, line => - { - outputLines.Add(line); - if (outputLines.Count == 1) - { - updateResource.SetResult(); - } - else if (outputLines.Count == 2) - { - cancellationTokenSource.Cancel(); - } - }); - - var snapshots = new List - { - new() - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Starting", - StateStyle = "info" - } - }; - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234, - CliProcessId = 5678 - }, - GetResourceSnapshotsHandler = _ => Task.FromResult(snapshots.ToList()), - WatchResourceSnapshotsHandler = WatchResourceSnapshotsAsync - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources --follow"); - - var exitCode = await result.InvokeAsync(cancellationToken: cancellationTokenSource.Token).DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - Assert.Equal(2, outputLines.Count); - - var initialAppHost = JsonSerializer.Deserialize(outputLines[0], PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo); - var updatedAppHost = JsonSerializer.Deserialize(outputLines[1], PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo); - Assert.NotNull(initialAppHost); - Assert.NotNull(updatedAppHost); - Assert.Equal(AppHostDisplayStatus.Running, initialAppHost.Status); - Assert.Equal(AppHostDisplayStatus.Running, updatedAppHost.Status); - Assert.Equal("Starting", Assert.Single(initialAppHost.Resources!).State); - Assert.Equal("Running", Assert.Single(updatedAppHost.Resources!).State); - - async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken) - { - Assert.False(includeHidden); - await updateResource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - snapshots[0] = new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running", - StateStyle = "success" - }; - yield return snapshots[0]; - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - } - } - [Fact] public async Task PsCommand_FollowJsonFormat_ReturnsSuccessWhenOutputCloses() { @@ -854,79 +548,6 @@ public async Task PsCommand_FollowJsonFormat_ReturnsSuccessWhenOutputCloses() Assert.Single(interactionService.DisplayedRawText); } - [Fact] - public async Task PsCommand_FollowJsonFormat_WaitsForResourceWatchersBeforeReturning() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - using var cancellationTokenSource = new CancellationTokenSource(); - var watcherCleanupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var allowWatcherCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var textWriter = new TestOutputTextWriter(outputHelper, _ => cancellationTokenSource.Cancel()); - var monitor = new TestAuxiliaryBackchannelMonitor(); - monitor.AddConnection("hash1", "socket.hash1", new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234, - CliProcessId = 5678 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running" - } - ], - WatchResourceSnapshotsHandler = WatchResourceSnapshotsAsync - }); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources --follow"); - - var invokeTask = result.InvokeAsync(cancellationToken: cancellationTokenSource.Token); - - await watcherCleanupStarted.Task.DefaultTimeout(); - try - { - Assert.False(invokeTask.IsCompleted, "The command returned before its resource watcher task completed cleanup."); - } - finally - { - allowWatcherCleanup.TrySetResult(); - } - - var exitCode = await invokeTask.DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken) - { - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - } - finally - { - watcherCleanupStarted.TrySetResult(); - await allowWatcherCleanup.Task.ConfigureAwait(false); - } - - yield break; - } - } - [Fact] public async Task PsCommand_FollowWithoutJsonFormat_ReturnsInvalidCommand() { @@ -1001,103 +622,6 @@ public async Task PsCommand_FollowJsonFormat_StreamsStoppedAppHostWhenConnection Assert.Equal(initialAppHost.AppHostPid, stoppedAppHost.AppHostPid); } - [Fact] - public async Task PsCommand_WithoutResourcesOption_OmitsResourcesFromJsonOutput() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - ResourceType = "Project", - State = "Running" - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); - Assert.NotNull(appHosts); - Assert.Single(appHosts); - Assert.Null(appHosts[0].Resources); - - // Also verify the raw JSON doesn't contain a "resources" key - var document = JsonDocument.Parse(jsonOutput); - var firstElement = document.RootElement[0]; - Assert.False(firstElement.TryGetProperty("resources", out _)); - } - - [Fact] - public async Task PsCommand_ResourcesOption_TableFormat_DoesNotFetchResources() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var resourcesFetched = false; - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - GetResourceSnapshotsHandler = _ => - { - resourcesFetched = true; - return Task.FromResult(new List - { - new ResourceSnapshot { Name = "apiservice", ResourceType = "Project", State = "Running" } - }); - } - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - // --resources with table format should not fetch resources - var result = command.Parse("ps --resources"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - Assert.False(resourcesFetched, "Resources should not be fetched when output format is table"); - } - [Fact] public async Task PsCommand_JsonFormat_IncludesLogFilePath() { From 25e96bd39208425d9769abd373e849e66ff49c80 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 26 May 2026 09:29:53 +1000 Subject: [PATCH 02/13] Restore resources in global multi-AppHost view via describe fan-out The previous commit removed --resources from 'aspire ps' but left the global (multi-AppHost) view in the VS Code extension with no source for per-AppHost resource data, since 'aspire describe --follow' is per-AppHost (takes a single --apphost). Add a per-AppHost describe fan-out in AppHostDataRepository: while the panel is visible in global mode, maintain one 'aspire describe --follow --apphost ' stream per AppHost discovered by 'ps', merging resources into appHost.resources so the tree provider's existing rendering path lights up again. Streams are reconciled on every ps update, torn down when an AppHost stops or when leaving global mode/hiding the panel, and restarted with exponential backoff if they exit unexpectedly. Update the global ps-follow test to feed resources via describe instead of ps, and add two new tests covering the fan-out reconciliation and the mode-switch teardown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/appHostDataRepository.test.ts | 125 ++++++++++-- extension/src/views/AppHostDataRepository.ts | 189 +++++++++++++++++- 2 files changed, 300 insertions(+), 14 deletions(-) diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 00d98759286..e7fe1254ffb 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -1017,50 +1017,56 @@ suite('AppHostDataRepository global polling', () => { assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json']); - const lineCallback = spawnStub.firstCall.args[3].lineCallback; - lineCallback(JSON.stringify({ + const psLineCallback = spawnStub.firstCall.args[3].lineCallback; + psLineCallback(JSON.stringify({ appHostPath: '/workspace/AppHost.csproj', appHostPid: 1234, status: 'running', - resources: [ - { name: 'api', resourceType: 'Project', state: 'Running' } - ] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 1); assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/AppHost.csproj'); + + // The repository should now have spawned `aspire describe --follow --apphost ` + // for the discovered AppHost so the global tree can show resources. + const describeCall = spawnStub.getCalls().find(call => + Array.isArray(call.args[2]) && call.args[2][0] === 'describe' && call.args[2].includes('/workspace/AppHost.csproj')); + assert.ok(describeCall, 'expected aspire describe --follow to spawn for the discovered AppHost'); + assert.deepStrictEqual(describeCall.args[2], ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/AppHost.csproj']); + + const describeLineCallback = describeCall.args[3].lineCallback; + describeLineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' })); assert.strictEqual(repository.appHosts[0].resources?.[0].name, 'api'); - lineCallback(JSON.stringify({ + psLineCallback(JSON.stringify({ appHostPath: '/workspace/OtherAppHost.csproj', appHostPid: 5678, status: 'running', - resources: [] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 2); assert.strictEqual(repository.appHosts[1].appHostPath, '/workspace/OtherAppHost.csproj'); assert.deepStrictEqual(repository.appHosts[1].resources, []); - lineCallback(JSON.stringify({ + psLineCallback(JSON.stringify({ appHostPath: '/workspace/AppHost.csproj', appHostPid: 9999, status: 'running', - resources: [] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 3); assert.strictEqual(repository.appHosts[2].appHostPath, '/workspace/AppHost.csproj'); assert.strictEqual(repository.appHosts[2].appHostPid, 9999); - lineCallback(JSON.stringify({ + psLineCallback(JSON.stringify({ appHostPath: '/workspace/AppHost.csproj', appHostPid: 1234, status: 'stopped', - resources: [ - { name: 'api', resourceType: 'Project', state: 'Running' } - ] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 2); assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/OtherAppHost.csproj'); @@ -1205,6 +1211,99 @@ suite('AppHostDataRepository global polling', () => { repository.dispose(); }); + + test('global mode spawns describe per AppHost and tears down on AppHost removal', async () => { + const spawned: { args: string[]; process: TestChildProcess; options: any }[] = []; + spawnStub.callsFake((_terminalProvider, _cliPath, args, options) => { + const process = new TestChildProcess(); + spawned.push({ args, process, options }); + return process; + }); + const repository = new AppHostDataRepository(terminalProvider); + + repository.activate(); + repository.setViewMode('global'); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + const psCall = spawned.find(call => call.args[0] === 'ps'); + assert.ok(psCall); + + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + status: 'running', + })); + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/OtherAppHost.csproj', + appHostPid: 5678, + status: 'running', + })); + await waitForMicrotasks(); + + const describeCalls = spawned.filter(call => call.args[0] === 'describe'); + assert.strictEqual(describeCalls.length, 2); + const paths = describeCalls.map(call => call.args[call.args.indexOf('--apphost') + 1]).sort(); + assert.deepStrictEqual(paths, ['/workspace/AppHost.csproj', '/workspace/OtherAppHost.csproj']); + + const firstDescribe = describeCalls.find(call => call.args.includes('/workspace/AppHost.csproj'))!; + firstDescribe.options.lineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' })); + firstDescribe.options.lineCallback(JSON.stringify({ name: 'db', resourceType: 'Container', state: 'Running' })); + + const first = repository.appHosts.find(a => a.appHostPath === '/workspace/AppHost.csproj'); + assert.ok(first); + assert.strictEqual(first.resources?.length, 2); + assert.deepStrictEqual(first.resources?.map(r => r.name).sort(), ['api', 'db']); + + // Stop the first AppHost — its describe stream should be torn down. + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + status: 'stopped', + })); + await waitForMicrotasks(); + + assert.strictEqual(firstDescribe.process.killed, true); + assert.strictEqual(repository.appHosts.length, 1); + assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/OtherAppHost.csproj'); + + repository.dispose(); + }); + + test('global describe streams are stopped when switching to workspace mode', async () => { + const spawned: { args: string[]; process: TestChildProcess; options: any }[] = []; + spawnStub.callsFake((_terminalProvider, _cliPath, args, options) => { + const process = new TestChildProcess(); + spawned.push({ args, process, options }); + return process; + }); + const repository = new AppHostDataRepository(terminalProvider); + + repository.activate(); + repository.setViewMode('global'); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + const psCall = spawned.find(call => call.args[0] === 'ps'); + assert.ok(psCall); + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + status: 'running', + })); + await waitForMicrotasks(); + + const describeCall = spawned.find(call => call.args[0] === 'describe'); + assert.ok(describeCall); + assert.strictEqual(describeCall.process.killed, false); + + repository.setViewMode('workspace'); + await waitForMicrotasks(); + + assert.strictEqual(describeCall.process.killed, true); + + repository.dispose(); + }); }); suite('AppHostDataRepository AppHost-file gate', () => { diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 1feabe2e1d1..caaae8e98fa 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -83,13 +83,26 @@ export interface AppHostDisplayInfo { export type ViewMode = 'workspace' | 'global'; +interface GlobalDescribeStream { + appHostPath: string; + process: ChildProcessWithoutNullStreams | undefined; + resources: Map; + restartTimer: ReturnType | undefined; + restartDelay: number; + version: number; +} + /** * Central data repository for app host and resource information. * - * Owns two independent data sources: + * Owns three independent data sources: * - `aspire describe --follow` (workspace mode) — streams resource updates * via NDJSON for the selected workspace AppHost. Only active while the * tree-view panel is visible **and** workspace mode is selected. + * - `aspire describe --follow --apphost ` (global mode fan-out) — one + * stream per AppHost discovered by `ps`, merged into `appHost.resources` + * so the global multi-AppHost tree can show nested resources. `ps` itself + * only emits AppHost-level data. * - `aspire ps` polling — periodically fetches running app hosts. In global * mode this backs the full tree; in workspace mode it confirms whether the * selected workspace AppHost is running when the resource stream is empty. @@ -123,6 +136,13 @@ export class AppHostDataRepository { private _supportsPsFollow = true; private _fetchInProgress = false; + // ── Global mode per-AppHost describe streams ── + // In global mode `ps` only returns AppHost-level data, so to populate + // `appHost.resources` for the multi-AppHost tree we fan out one + // `aspire describe --follow --apphost ` per discovered AppHost and + // merge the streams. Keyed by appHostPath. + private _globalDescribeStreams = new Map(); + // ── Workspace app host (from aspire ls) ── // The singular fields track a selected/default workspace AppHost. The candidate // paths track every buildable AppHost found by `aspire ls`, so workspace-mode @@ -258,6 +278,7 @@ export class AppHostDataRepository { this._disposed = true; this._stopPolling(); this._stopDescribeWatch(); + this._stopAllGlobalDescribes(); if (this._getAppHostsProcess) { const getAppHostsProcess = this._getAppHostsProcess; this._getAppHostsProcess = undefined; @@ -312,6 +333,12 @@ export class AppHostDataRepository { } else { this._stopPolling(); } + + // Global describe fan-out is only active while in global mode with the + // panel/editor showing. _reconcileGlobalDescribes handles both starting + // streams (when there are AppHosts to follow) and tearing them down + // (when we leave global mode or hide the panel). + this._reconcileGlobalDescribes(); } // ── Workspace app host (from aspire ls) ── @@ -603,6 +630,165 @@ export class AppHostDataRepository { return undefined; } + // ── Global mode: per-AppHost describe fan-out ── + // `ps` is AppHost-level only, so to keep the global multi-AppHost tree + // populated with resources we spin up one `aspire describe --follow --apphost ` + // per AppHost in `_appHosts` and merge the streams into appHost.resources. + + private _reconcileGlobalDescribes(): void { + if (this._disposed || this._viewMode !== 'global' || !this._dataActive) { + this._stopAllGlobalDescribes(); + return; + } + + const currentPaths = new Set(this._appHosts.map(a => a.appHostPath)); + for (const path of Array.from(this._globalDescribeStreams.keys())) { + if (!currentPaths.has(path)) { + this._stopGlobalDescribe(path); + } + } + for (const appHost of this._appHosts) { + if (!this._globalDescribeStreams.has(appHost.appHostPath)) { + this._startGlobalDescribe(appHost.appHostPath); + } + } + this._attachGlobalResourcesToAppHosts(); + } + + private _attachGlobalResourcesToAppHosts(): void { + for (const appHost of this._appHosts) { + const stream = this._globalDescribeStreams.get(appHost.appHostPath); + appHost.resources = stream ? Array.from(stream.resources.values()) : null; + } + } + + private _startGlobalDescribe(appHostPath: string): void { + const stream: GlobalDescribeStream = { + appHostPath, + process: undefined, + resources: new Map(), + restartTimer: undefined, + restartDelay: 5000, + version: 0, + }; + this._globalDescribeStreams.set(appHostPath, stream); + const startVersion = ++stream.version; + + this._terminalProvider.getAspireCliExecutablePath().then(cliPath => { + // Bail if we were stopped, replaced, or torn down while resolving the cli path. + if (this._disposed || this._globalDescribeStreams.get(appHostPath) !== stream || startVersion !== stream.version) { + return; + } + + const args = ['describe', '--follow', '--format', 'json', '--apphost', appHostPath]; + extensionLogOutputChannel.info(`Starting aspire describe --follow for AppHost ${appHostPath}`); + + const childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + noExtensionVariables: true, + lineCallback: (line) => { + if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) { + return; + } + this._handleGlobalDescribeLine(stream, line); + }, + stderrCallback: () => { + // Per-AppHost describe errors should not pollute the global error banner. + }, + exitCallback: (code) => { + if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) { + return; + } + extensionLogOutputChannel.info(`aspire describe --follow for ${appHostPath} exited with code ${code}`); + stream.process = undefined; + if (this._disposed) { + return; + } + + // AppHost is no longer running — drop the stream entirely; the + // next ps reconcile will recreate it if the AppHost comes back. + if (!this._appHosts.some(a => a.appHostPath === appHostPath)) { + this._globalDescribeStreams.delete(appHostPath); + return; + } + + stream.resources.clear(); + this._attachGlobalResourcesToAppHosts(); + this._onDidChangeData.fire(); + + const delay = stream.restartDelay; + stream.restartDelay = Math.min(stream.restartDelay * 2, this._getPollingIntervalMs()); + stream.restartTimer = setTimeout(() => { + stream.restartTimer = undefined; + if (this._disposed) { + return; + } + if (this._globalDescribeStreams.get(appHostPath) !== stream) { + return; + } + if (!this._appHosts.some(a => a.appHostPath === appHostPath)) { + this._globalDescribeStreams.delete(appHostPath); + return; + } + this._globalDescribeStreams.delete(appHostPath); + this._startGlobalDescribe(appHostPath); + }, delay); + }, + errorCallback: (error) => { + if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) { + return; + } + extensionLogOutputChannel.warn(`aspire describe --follow for ${appHostPath} error: ${error.message}`); + stream.process = undefined; + } + }); + stream.process = childProcess; + }).catch(error => { + extensionLogOutputChannel.warn(`Failed to start describe for ${appHostPath}: ${error}`); + }); + } + + private _handleGlobalDescribeLine(stream: GlobalDescribeStream, line: string): void { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + try { + const resource: ResourceJson = JSON.parse(trimmed); + if (resource.name) { + stream.resources.set(resource.name, resource); + stream.restartDelay = 5000; + this._attachGlobalResourcesToAppHosts(); + this._onDidChangeData.fire(); + } + } catch (e) { + extensionLogOutputChannel.warn(`Failed to parse describe NDJSON line for ${stream.appHostPath}: ${e}`); + } + } + + private _stopGlobalDescribe(appHostPath: string): void { + const stream = this._globalDescribeStreams.get(appHostPath); + if (!stream) { + return; + } + this._globalDescribeStreams.delete(appHostPath); + stream.version++; + if (stream.restartTimer) { + clearTimeout(stream.restartTimer); + stream.restartTimer = undefined; + } + if (stream.process) { + const childProcess = stream.process; + stream.process = undefined; + this._terminateProcess(childProcess, `aspire describe --follow (${appHostPath})`); + } + } + + private _stopAllGlobalDescribes(): void { + for (const path of Array.from(this._globalDescribeStreams.keys())) { + this._stopGlobalDescribe(path); + } + } + private _updateWorkspaceContext(options?: { clearLoading?: boolean }): void { const hasWorkspaceAppHost = this._workspaceAppHost !== undefined; const hasResources = this._workspaceResources.size > 0; @@ -836,6 +1022,7 @@ export class AppHostDataRepository { const changed = JSON.stringify(appHosts) !== JSON.stringify(this._appHosts); this._appHosts = appHosts; + this._reconcileGlobalDescribes(); if (this._loadingGlobal) { this._loadingGlobal = false; From c44d85103f4d81ad616408204511a269c06d58d7 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 26 May 2026 10:00:54 +1000 Subject: [PATCH 03/13] Remove resources from ps test mocks Update AppHostDataRepository tests so ps payload fixtures match the current aspire ps output shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/test/appHostDataRepository.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index e7fe1254ffb..56aba566f9d 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -257,7 +257,6 @@ suite('AppHostDataRepository', () => { appHostPid: 1234, cliPid: null, dashboardUrl: null, - resources: null, }])); assert.ok(repository.errorMessage?.includes('describe failed'), repository.errorMessage); @@ -485,7 +484,6 @@ suite('AppHostDataRepository', () => { { appHostPath: configuredAppHostPath, appHostPid: 125881, - resources: [], }, ])); assert.strictEqual(repository.workspaceAppHost?.appHostPath, configuredAppHostPath); @@ -679,7 +677,6 @@ suite('AppHostDataRepository', () => { appHostPid: 125881, cliPid: 125738, dashboardUrl: 'https://localhost:17193/login?t=061212', - resources: [], }])); assert.strictEqual(repository.workspaceResources.length, 0); @@ -753,7 +750,6 @@ suite('AppHostDataRepository', () => { appHostPid: 125881, cliPid: 125738, dashboardUrl: 'https://localhost:17193/login?t=061212', - resources: [], }, ])); @@ -879,7 +875,6 @@ suite('AppHostDataRepository', () => { { appHostPath: '/workspace/labs/ops/apphost.cs', appHostPid: 125881, - resources: [], }, ])); From 231fdd30bcc403a896d42c891019ae858b74eaa5 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 26 May 2026 10:07:09 +1000 Subject: [PATCH 04/13] Remove stale aspire ps resources comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Shared/Model/Serialization/ResourceJson.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Shared/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs index 113ece99d10..a2eecc9a092 100644 --- a/src/Shared/Model/Serialization/ResourceJson.cs +++ b/src/Shared/Model/Serialization/ResourceJson.cs @@ -10,8 +10,8 @@ namespace Aspire.Shared.Model.Serialization; /// Represents a resource in JSON format. /// This is a shared representation used by both the Dashboard and CLI. /// -// CLI commands such as `aspire describe --format json` and `aspire ps --format json --resources` -// use this shape and the nested resource shapes below; keep docs/specs/cli-output-formats.md in sync when changing them. +// `aspire describe --format json` uses this shape and the nested resource shapes below; +// keep docs/specs/cli-output-formats.md in sync when changing them. internal sealed class ResourceJson { /// From 462d739fde452026a7d71fa496c80008f158c183 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 26 May 2026 10:29:51 +1000 Subject: [PATCH 05/13] Make Windows-flaky workspace AppHost discovery test deterministic The 'possibly unbuildable AppHost candidates do not force global mode' test races on Windows CI because the aspire ls exit handler awaits getConfiguredAppHostPathFromWorkspaceRoot, which probes for the workspace config files via vscode workspace fs. That probe can take more than the single setTimeout(0) tick that waitForAppHostDiscovery covers, so workspaceAppHostPath was still undefined when the assertion ran. Switch to the existing waitForCondition polling helper so the test waits until the path is actually populated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/test/appHostDataRepository.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 56aba566f9d..114bb4aba42 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -561,7 +561,13 @@ suite('AppHostDataRepository', () => { status: 'possibly-unbuildable', }, ])); - await waitForAppHostDiscovery(); + // aspire ls exit handler awaits getConfiguredAppHostPathFromWorkspaceRoot, which + // probes for aspire.config.json / .aspire/settings.json via vscode workspace fs. + // That probe can take more than one macrotask on Windows, so poll for completion + // instead of relying on a single setTimeout(0) tick. + await waitForCondition( + () => repository.workspaceAppHostPath !== undefined, + 'expected workspace AppHost path to be set after aspire ls discovery'); assert.strictEqual(repository.viewMode, 'workspace'); assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj'); From c223ecebd6cbd0cc030a27cecdc5ce50f3dcf2ab Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 26 May 2026 13:35:28 +1000 Subject: [PATCH 06/13] Recover from describe spawn errors in global fan-out Node's spawn can emit error (e.g., ENOENT when the CLI binary is missing or its path changes) without a subsequent exit. The previous errorCallback only cleared stream.process, leaving a zombie entry in _globalDescribeStreams that blocked _reconcileGlobalDescribes from recreating the stream. Drop the entry (and clear cached resources + fire a change event so the UI reflects the removal) so the next ps reconcile recreates it from scratch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/src/views/AppHostDataRepository.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index caaae8e98fa..3b7c3bd00cc 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -739,6 +739,14 @@ export class AppHostDataRepository { } extensionLogOutputChannel.warn(`aspire describe --follow for ${appHostPath} error: ${error.message}`); stream.process = undefined; + // Node's `spawn` can fire `error` (e.g., ENOENT when the CLI binary is missing) + // without a subsequent `exit`, which would normally drive the restart loop. + // Drop the dead entry so the next ps reconcile recreates it instead of leaving + // a zombie that blocks reconcile from re-starting the stream. + this._globalDescribeStreams.delete(appHostPath); + stream.resources.clear(); + this._attachGlobalResourcesToAppHosts(); + this._onDidChangeData.fire(); } }); stream.process = childProcess; From 4b6a001d3b60a77335b3e9a1f2c511936ff047e4 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 27 May 2026 17:30:03 -0400 Subject: [PATCH 07/13] Use Yarn Classic for extension build scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extension/Extension.proj | 10 +++++----- extension/build.ps1 | 11 +++++------ extension/build.sh | 11 +++++------ 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/extension/Extension.proj b/extension/Extension.proj index ff8c1048973..5436af60c54 100644 --- a/extension/Extension.proj +++ b/extension/Extension.proj @@ -31,13 +31,13 @@ - - - + + @@ -88,12 +88,12 @@ - + - + diff --git a/extension/build.ps1 b/extension/build.ps1 index 21069d7ff13..ccd4be4305b 100644 --- a/extension/build.ps1 +++ b/extension/build.ps1 @@ -10,10 +10,9 @@ if (-not (Get-Command node -ErrorAction SilentlyContinue)) { exit 1 } -# Check for yarn -if (-not (Get-Command yarn -ErrorAction SilentlyContinue)) { - Write-Error "Error: yarn is not installed. Please install yarn first." - Write-Host "You can install yarn by running: npm install -g yarn" +# Check for Corepack so the build uses the Yarn Classic version that matches extension/yarn.lock. +if (-not (Get-Command corepack -ErrorAction SilentlyContinue)) { + Write-Error "Error: Corepack is not installed. Please install a Node.js version that includes Corepack." exit 1 } @@ -41,7 +40,7 @@ Set-Location $PSScriptRoot Write-Host "" Write-Host "Running yarn install..." -yarn install --frozen-lockfile --non-interactive +corepack yarn@1.22.22 install --frozen-lockfile --non-interactive if ($LASTEXITCODE -ne 0) { Write-Error "yarn install failed with exit code $LASTEXITCODE" @@ -50,7 +49,7 @@ if ($LASTEXITCODE -ne 0) { Write-Host "" Write-Host "Running yarn compile..." -yarn compile +corepack yarn@1.22.22 compile if ($LASTEXITCODE -ne 0) { Write-Error "yarn compile failed with exit code $LASTEXITCODE" diff --git a/extension/build.sh b/extension/build.sh index 4b7c7e79bac..110967f4215 100755 --- a/extension/build.sh +++ b/extension/build.sh @@ -9,10 +9,9 @@ if ! command -v node &> /dev/null; then exit 1 fi -# Check for yarn -if ! command -v yarn &> /dev/null; then - echo "Error: yarn is not installed. Please install yarn first." - echo "You can install yarn by running: npm install -g yarn" +# Check for Corepack so the build uses the Yarn Classic version that matches extension/yarn.lock. +if ! command -v corepack &> /dev/null; then + echo "Error: Corepack is not installed. Please install a Node.js version that includes Corepack." exit 1 fi @@ -38,11 +37,11 @@ cd "$SCRIPT_DIR" echo "" echo "Running yarn install..." -yarn install --frozen-lockfile --non-interactive +corepack yarn@1.22.22 install --frozen-lockfile --non-interactive echo "" echo "Running yarn compile..." -yarn compile +corepack yarn@1.22.22 compile echo "" echo "Building Aspire CLI..." From 0cc72a8bf945c9786cf0e04fe34228396c6d15ca Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 27 May 2026 17:50:00 -0400 Subject: [PATCH 08/13] Fix workspace AppHost state refresh Clear stale workspace AppHost state as soon as a working describe stream exits, and restart describe when ps observes the selected AppHost starting after an earlier empty describe exit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/appHostDataRepository.test.ts | 137 ++++++++++++++++++ extension/src/views/AppHostDataRepository.ts | 18 +++ 2 files changed, 155 insertions(+) diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index b8115873cb2..1659aca0a10 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -907,6 +907,143 @@ suite('AppHostDataRepository', () => { } }); + test('workspace describe exit clears stale running AppHost before ps stop snapshot', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + const executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcess = new TestChildProcess(); + let describeOptions: any; + let psOptions: any; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeOptions = options; + return describeProcess; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + assert.ok(getAppHostsLineCallback); + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/labs/ops/apphost.cs', + all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'], + })); + await waitForAppHostDiscovery(); + + assert.ok(describeOptions); + assert.ok(psOptions); + describeOptions.lineCallback(JSON.stringify({ name: 'worker', resourceType: 'Project', state: 'Running' })); + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/labs/ops/apphost.cs', + appHostPid: 125881, + }, + ])); + + assert.strictEqual(repository.workspaceResources.length, 1); + assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881); + assert.strictEqual(repository.appHosts.length, 1); + + describeOptions.exitCallback(0); + + assert.strictEqual(repository.workspaceResources.length, 0); + assert.strictEqual(repository.workspaceAppHost, undefined); + assert.strictEqual(repository.appHosts.length, 0); + + const noRunningContextCalls = executeCommandStub.getCalls().filter(call => + call.args[0] === 'setContext' && call.args[1] === 'aspire.noRunningAppHosts'); + assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], true); + } finally { + repository.dispose(); + executeCommandStub.restore(); + workspaceFoldersStub.restore(); + } + }); + + test('workspace ps start restarts describe after earlier empty describe exit', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + let psOptions: any; + const describeProcesses: TestChildProcess[] = []; + const describeOptions: any[] = []; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeOptions.push(options); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + assert.ok(getAppHostsLineCallback); + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/labs/ops/apphost.cs', + all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'], + })); + await waitForAppHostDiscovery(); + + assert.strictEqual(describeOptions.length, 1); + describeOptions[0].exitCallback(0); + assert.strictEqual(repository.workspaceResources.length, 0); + assert.strictEqual(Boolean(repository.workspaceAppHost), false); + + assert.ok(psOptions); + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/labs/ops/apphost.cs', + appHostPid: 125881, + }, + ])); + await waitForMicrotasks(); + + assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881); + assert.strictEqual(describeOptions.length, 2); + + describeOptions[1].lineCallback(JSON.stringify({ name: 'worker', resourceType: 'Project', state: 'Running' })); + assert.strictEqual(repository.workspaceResources.length, 1); + assert.strictEqual(repository.workspaceResources[0].name, 'worker'); + assert.strictEqual(describeProcesses[0].killed, false); + assert.strictEqual(describeProcesses[1].killed, false); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + test('late close from stopped describe watch does not orphan replacement watch', async () => { const firstChildProcess = new TestChildProcess(); const secondChildProcess = new TestChildProcess(); diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index cb6defd1faa..284f4a4755c 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -515,6 +515,7 @@ export class AppHostDataRepository { // once more with backoff in case the apphost is restarting; if that // attempt also produces no data we'll fall into the branch above. this._workspaceResources.clear(); + this._clearStoppedWorkspaceAppHost(); this._setDescribeError(undefined); this._updateWorkspaceContext(); @@ -598,6 +599,14 @@ export class AppHostDataRepository { } } + private _clearStoppedWorkspaceAppHost(): void { + const appHostPath = this._workspaceAppHost?.appHostPath ?? this._workspaceAppHostPath; + this._workspaceAppHost = undefined; + this._appHosts = appHostPath + ? this._appHosts.filter(appHost => !isMatchingAppHostPath(appHost.appHostPath, appHostPath)) + : []; + } + private _handleDescribeLine(line: string): boolean { const trimmed = line.trim(); if (!trimmed) { @@ -1068,6 +1077,7 @@ export class AppHostDataRepository { const workspaceAppHost = workspaceAppHostPath ? workspaceAppHosts.find(appHost => isMatchingAppHostPath(appHost.appHostPath, workspaceAppHostPath)) : undefined; + const workspaceAppHostStarted = workspaceAppHost !== undefined && this._workspaceAppHost === undefined; const changed = JSON.stringify(workspaceAppHosts) !== JSON.stringify(this._appHosts) || JSON.stringify(workspaceAppHost) !== JSON.stringify(this._workspaceAppHost); @@ -1078,6 +1088,14 @@ export class AppHostDataRepository { this._appHosts = workspaceAppHosts; this._workspaceAppHost = workspaceAppHost; + if (workspaceAppHostStarted + && this._shouldWatchWorkspace + && !this._describeProcess + && !this._describeStartPending + && !this._describeRestartTimer) { + this._startDescribeWatch(); + } + if (changed || this._loadingWorkspace) { this._updateWorkspaceContext({ clearLoading: true }); } From 232ba9a204df93c7f002c85cb012290bf4f065db Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 27 May 2026 18:42:53 -0400 Subject: [PATCH 09/13] Retarget workspace describe to running AppHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/appHostDataRepository.test.ts | 72 +++++++++++++++++++ extension/src/views/AppHostDataRepository.ts | 28 +++++++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 1659aca0a10..c12dcf9b8a7 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -422,6 +422,78 @@ suite('AppHostDataRepository', () => { } }); + test('multi-AppHost workspace retargets describe to the only running AppHost', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcesses: TestChildProcess[] = []; + const describeCalls: { args: string[]; options: any }[] = []; + let psOptions: any; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeCalls.push({ args, options }); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForAppHostDiscovery(); + assert.ok(getAppHostsLineCallback); + + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/apps/Store/AppHost.csproj', + all_project_file_candidates: [ + '/workspace/apps/Store/AppHost.csproj', + '/workspace/samples/Store/AppHost.csproj', + ], + })); + await waitForAppHostDiscovery(); + + assert.strictEqual(describeCalls.length, 1); + assert.deepStrictEqual(describeCalls[0].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']); + assert.ok(psOptions); + + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/samples/Store/AppHost.csproj', + appHostPid: 125881, + cliPid: 125738, + dashboardUrl: 'https://localhost:17193/login?t=061212', + }, + ])); + await waitForMicrotasks(); + + assert.strictEqual(repository.workspaceAppHostPath, '/workspace/samples/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHostName, 'samples/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881); + assert.strictEqual(describeProcesses[0].killed, true); + assert.strictEqual(describeCalls.length, 2); + assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']); + + describeCalls[1].options.lineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' })); + assert.strictEqual(repository.workspaceResources.length, 1); + assert.strictEqual(repository.workspaceResources[0].name, 'api'); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + test('configured AppHost outside aspire ls candidates remains selected in workspace view', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-extension-workspace-')); const configuredAppHostPath = path.join(path.dirname(workspaceRoot), 'external', 'AppHost.csproj'); diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 284f4a4755c..7d8aa8d1413 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -426,6 +426,13 @@ export class AppHostDataRepository { this._workspaceAppHostName = candidateIndex >= 0 ? appHostLabels[candidateIndex] : shortenPath(appHostPath); } + private _setWorkspaceAppHostPathFromCurrentCandidates(appHostPath: string): void { + this._workspaceAppHostPath = appHostPath; + const appHostLabels = shortenPaths(this._workspaceAppHostCandidatePaths); + const candidateIndex = this._workspaceAppHostCandidatePaths.findIndex(candidatePath => isMatchingAppHostPath(candidatePath, appHostPath)); + this._workspaceAppHostName = candidateIndex >= 0 ? appHostLabels[candidateIndex] : shortenPath(appHostPath); + } + private _setWorkspaceAppHostCandidatePaths(appHostCandidates: readonly AppHostCandidate[]): void { this._workspaceAppHostCandidatePaths = appHostCandidates.map(candidate => candidate.path); } @@ -1070,14 +1077,29 @@ export class AppHostDataRepository { } private _handleWorkspacePsOutput(appHosts: readonly AppHostDisplayInfo[]): void { - const workspaceAppHostPath = this._workspaceAppHostPath; + let workspaceAppHostPath = this._workspaceAppHostPath; const workspaceAppHosts = this._workspaceAppHostCandidatePaths.length > 0 ? appHosts.filter(appHost => this._workspaceAppHostCandidatePaths.some(candidatePath => isMatchingAppHostPath(appHost.appHostPath, candidatePath))) : []; - const workspaceAppHost = workspaceAppHostPath + let workspaceAppHost = workspaceAppHostPath ? workspaceAppHosts.find(appHost => isMatchingAppHostPath(appHost.appHostPath, workspaceAppHostPath)) : undefined; - const workspaceAppHostStarted = workspaceAppHost !== undefined && this._workspaceAppHost === undefined; + let workspaceAppHostPathChanged = false; + + if (!workspaceAppHost && workspaceAppHosts.length === 1) { + workspaceAppHost = workspaceAppHosts[0]; + workspaceAppHostPathChanged = !isMatchingAppHostPath(workspaceAppHostPath, workspaceAppHost.appHostPath); + if (workspaceAppHostPathChanged) { + extensionLogOutputChannel.info(`Retargeting workspace AppHost describe to running AppHost ${workspaceAppHost.appHostPath}`); + this._stopDescribeWatch({ clearWorkspaceResources: true }); + this._setWorkspaceAppHostPathFromCurrentCandidates(workspaceAppHost.appHostPath); + workspaceAppHostPath = this._workspaceAppHostPath; + this._setDescribeError(undefined); + this._describeRestartDelay = 5000; + } + } + + const workspaceAppHostStarted = workspaceAppHost !== undefined && (this._workspaceAppHost === undefined || workspaceAppHostPathChanged); const changed = JSON.stringify(workspaceAppHosts) !== JSON.stringify(this._appHosts) || JSON.stringify(workspaceAppHost) !== JSON.stringify(this._workspaceAppHost); From 563d0ab3da5b22f23f324c3df83b5e4139b2d713 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 27 May 2026 19:00:39 -0400 Subject: [PATCH 10/13] Cover ambiguous workspace AppHost retargeting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/appHostDataRepository.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index c12dcf9b8a7..40dcadeb101 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -494,6 +494,81 @@ suite('AppHostDataRepository', () => { } }); + test('multi-AppHost workspace does not retarget describe when multiple candidate AppHosts are running', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcesses: TestChildProcess[] = []; + const describeCalls: { args: string[]; options: any }[] = []; + let psOptions: any; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeCalls.push({ args, options }); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForAppHostDiscovery(); + assert.ok(getAppHostsLineCallback); + + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/apps/Store/AppHost.csproj', + all_project_file_candidates: [ + '/workspace/apps/Store/AppHost.csproj', + '/workspace/samples/Store/AppHost.csproj', + '/workspace/tools/Admin/AppHost.csproj', + ], + })); + await waitForAppHostDiscovery(); + + assert.strictEqual(describeCalls.length, 1); + assert.deepStrictEqual(describeCalls[0].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']); + describeProcesses[0].emit('close', 0); + await waitForMicrotasks(); + assert.ok(psOptions); + + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/samples/Store/AppHost.csproj', + appHostPid: 125881, + cliPid: 125738, + dashboardUrl: 'https://localhost:17193/login?t=061212', + }, + { + appHostPath: '/workspace/tools/Admin/AppHost.csproj', + appHostPid: 125882, + cliPid: 125739, + dashboardUrl: 'https://localhost:17194/login?t=061213', + }, + ])); + await waitForMicrotasks(); + + assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHostName, 'apps/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHost, undefined); + assert.strictEqual(describeCalls.length, 1); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + test('configured AppHost outside aspire ls candidates remains selected in workspace view', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-extension-workspace-')); const configuredAppHostPath = path.join(path.dirname(workspaceRoot), 'external', 'AppHost.csproj'); From cdabac49fc65aa26ddf2fbe821ac5f5880cc1954 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 27 May 2026 23:03:48 -0400 Subject: [PATCH 11/13] bump version --- extension/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/package.json b/extension/package.json index e3106820e0d..095c39616c3 100644 --- a/extension/package.json +++ b/extension/package.json @@ -3,7 +3,7 @@ "displayName": "Aspire", "description": "%extension.description%", "publisher": "microsoft-aspire", - "version": "1.0.9", + "version": "1.10.0", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "icon": "dotnet-aspire-logo-128.png", "license": "SEE LICENSE IN LICENSE.TXT", From 43bc29a5abc51db562c9097f718ab05c740842fc Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 27 May 2026 23:12:52 -0400 Subject: [PATCH 12/13] Start per-AppHost describe streams for non-selected workspace AppHosts In multi-AppHost workspace mode, non-selected running AppHosts had no resources displayed because only the selected AppHost got a workspace describe stream. This adds _reconcileWorkspaceDescribes() which fans out global describe streams for every running workspace AppHost that is not the selected one, so the tree view can show their resources. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/appHostDataRepository.test.ts | 91 +++++++++++++++++++ extension/src/views/AppHostDataRepository.ts | 46 ++++++++++ 2 files changed, 137 insertions(+) diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 40dcadeb101..ac220740e59 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -562,7 +562,98 @@ suite('AppHostDataRepository', () => { assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj'); assert.strictEqual(repository.workspaceAppHostName, 'apps/Store/AppHost.csproj'); assert.strictEqual(repository.workspaceAppHost, undefined); + // No retarget, but global describe streams start for the non-selected running AppHosts + // so their resources appear in the workspace tree. + assert.strictEqual(describeCalls.length, 3); + assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']); + assert.deepStrictEqual(describeCalls[2].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/tools/Admin/AppHost.csproj']); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + + test('non-selected running AppHosts in workspace get resources from per-AppHost describe streams', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcesses: TestChildProcess[] = []; + const describeCalls: { args: string[]; options: any }[] = []; + let psOptions: any; + spawnStub.callsFake((_terminalProvider: any, _command: any, args: string[], options: any) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeCalls.push({ args, options }); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForAppHostDiscovery(); + assert.ok(getAppHostsLineCallback); + + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/apps/Store/AppHost.csproj', + all_project_file_candidates: [ + '/workspace/apps/Store/AppHost.csproj', + '/workspace/samples/Store/AppHost.csproj', + ], + })); + await waitForAppHostDiscovery(); assert.strictEqual(describeCalls.length, 1); + describeProcesses[0].emit('close', 0); + await waitForMicrotasks(); + assert.ok(psOptions); + + // Simulate both AppHosts running + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/apps/Store/AppHost.csproj', + appHostPid: 125880, + cliPid: 125737, + dashboardUrl: 'https://localhost:17192/login?t=061211', + }, + { + appHostPath: '/workspace/samples/Store/AppHost.csproj', + appHostPid: 125881, + cliPid: 125738, + dashboardUrl: 'https://localhost:17193/login?t=061212', + }, + ])); + await waitForMicrotasks(); + + // Global describe for non-selected AppHost spawns asynchronously after resolving CLI path + await waitForCondition(() => describeCalls.length >= 2, 'global describe for non-selected AppHost should start'); + + // Initial workspace describe + global describe for the non-selected AppHost + // (workspace describe restart is still pending on a timer) + assert.strictEqual(describeCalls.length, 2); + assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']); + + // Simulate resource data arriving on the non-selected AppHost's describe stream (NDJSON format) + describeCalls[1].options.lineCallback(JSON.stringify({ name: 'redis', resourceType: 'Container', state: 'Running' })); + await waitForMicrotasks(); + + // The non-selected AppHost should have its resources populated + const nonSelectedAppHost = repository.appHosts.find((a: any) => a.appHostPath === '/workspace/samples/Store/AppHost.csproj'); + assert.ok(nonSelectedAppHost); + assert.ok(nonSelectedAppHost.resources); + assert.strictEqual(nonSelectedAppHost.resources!.length, 1); + assert.strictEqual(nonSelectedAppHost.resources![0].name, 'redis'); } finally { repository.dispose(); workspaceFoldersStub.restore(); diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 7d8aa8d1413..dc0f548359a 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -274,6 +274,7 @@ export class AppHostDataRepository { refresh(): void { this._stopDescribeWatch(); + this._stopAllGlobalDescribes(); this._workspaceResources.clear(); this._clearErrors(); this._updateWorkspaceContext(); @@ -1110,6 +1111,14 @@ export class AppHostDataRepository { this._appHosts = workspaceAppHosts; this._workspaceAppHost = workspaceAppHost; + // When multiple workspace AppHost candidates exist, start per-AppHost describe + // streams for running AppHosts that are NOT the selected one (the workspace + // describe stream already handles the selected AppHost). This ensures every + // running AppHost displayed in the multi-AppHost workspace tree has resources. + if (this._workspaceAppHostCandidatePaths.length > 1) { + this._reconcileWorkspaceDescribes(workspaceAppHosts); + } + if (workspaceAppHostStarted && this._shouldWatchWorkspace && !this._describeProcess @@ -1123,6 +1132,43 @@ export class AppHostDataRepository { } } + /** + * In multi-candidate workspace mode, start/stop per-AppHost describe streams for + * running workspace AppHosts that are NOT the currently selected one. The workspace + * describe stream (via `_startDescribeWatch`) handles the selected AppHost; this + * method fans out global describe streams for the remaining running AppHosts so that + * each one displayed in the workspace tree has its resources populated. + */ + private _reconcileWorkspaceDescribes(workspaceAppHosts: readonly AppHostDisplayInfo[]): void { + const selectedPath = this._workspaceAppHostPath; + + // Determine which non-selected workspace AppHosts need a describe stream. + const desiredPaths = new Set( + workspaceAppHosts + .filter(a => !selectedPath || !isMatchingAppHostPath(a.appHostPath, selectedPath)) + .map(a => a.appHostPath) + ); + + // Stop streams for AppHosts that are no longer running (or became selected). + for (const path of Array.from(this._globalDescribeStreams.keys())) { + if (!desiredPaths.has(path)) { + this._stopGlobalDescribe(path); + } + } + + // Start streams for newly running non-selected AppHosts. + for (const appHost of workspaceAppHosts) { + if (selectedPath && isMatchingAppHostPath(appHost.appHostPath, selectedPath)) { + continue; + } + if (!this._globalDescribeStreams.has(appHost.appHostPath)) { + this._startGlobalDescribe(appHost.appHostPath); + } + } + + this._attachGlobalResourcesToAppHosts(); + } + private async _runPsCommand(args: string[], fetchVersion: number, callback: (code: number, stdout: string, stderr: string) => void): Promise { let cliPath: string; try { From 2ed8a3cd82cd99e2846e7dc5f53f20f41a924d79 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 27 May 2026 23:34:59 -0400 Subject: [PATCH 13/13] Address review feedback on per-AppHost describe fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes three bugs and one test issue flagged in the adversarial review of #17479: 1. Drop zombie _globalDescribeStreams entry when getAspireCliExecutablePath rejects. Mirrors the errorCallback fix at line 766 — without this, a one-time CLI path resolution failure leaves a permanent map entry that blocks _reconcileGlobalDescribes from ever re-spawning the stream. 2. Compute the 'changed' flag in _handlePsOutput against a cached post-reconcile snapshot instead of stringifying the raw ps payload. Raw 'ps' no longer emits a 'resources' field after #17479, but _attachGlobalResourcesToAppHosts mutates this._appHosts to add it, so a direct JSON.stringify compare reported 'changed' on every ps update and fired _onDidChangeData (and setContext) spuriously. 3. Log per-AppHost describe stderr to extensionLogOutputChannel so users can diagnose missing resources for non-selected AppHosts (e.g., CLI too old to support 'describe --apphost '). The error banner is still left untouched. 4. Remove dead emit('close', 0) calls in two new tests. The stub bypasses spawnCliProcess (which is where production wires the 'close' -> exitCallback handler), so the emit was a no-op and the following waitForMicrotasks() was likewise pointless. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test/appHostDataRepository.test.ts | 4 --- extension/src/views/AppHostDataRepository.ts | 31 +++++++++++++++++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index ac220740e59..145f7f5ba27 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -539,8 +539,6 @@ suite('AppHostDataRepository', () => { assert.strictEqual(describeCalls.length, 1); assert.deepStrictEqual(describeCalls[0].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']); - describeProcesses[0].emit('close', 0); - await waitForMicrotasks(); assert.ok(psOptions); psOptions.lineCallback(JSON.stringify([ @@ -615,8 +613,6 @@ suite('AppHostDataRepository', () => { })); await waitForAppHostDiscovery(); assert.strictEqual(describeCalls.length, 1); - describeProcesses[0].emit('close', 0); - await waitForMicrotasks(); assert.ok(psOptions); // Simulate both AppHosts running diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index dc0f548359a..4bb6b4e351a 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -136,6 +136,11 @@ export class AppHostDataRepository { // ── Running AppHost state (ps polling) ── private _appHosts: AppHostDisplayInfo[] = []; + // Cached JSON serialization of `_appHosts` after the most recent reconcile so + // _handlePsOutput can detect real changes. We can't compare raw `ps` output to + // `_appHosts` directly because the in-memory state has merged resources, while + // `ps` no longer emits them (#17479) — see _handlePsOutput for the rationale. + private _appHostsSnapshot = '[]'; private _workspaceAppHost: AppHostDisplayInfo | undefined; private _pollingInterval: ReturnType | undefined; private _psProcesses = new Set(); @@ -711,8 +716,11 @@ export class AppHostDataRepository { } this._handleGlobalDescribeLine(stream, line); }, - stderrCallback: () => { - // Per-AppHost describe errors should not pollute the global error banner. + stderrCallback: (data) => { + // Per-AppHost describe errors should not pollute the global error banner, + // but they MUST be logged so users can diagnose missing resources for + // non-selected AppHosts (e.g., CLI too old to support `describe --apphost`). + extensionLogOutputChannel.warn(`aspire describe --follow stderr for ${appHostPath}: ${data}`); }, exitCallback: (code) => { if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) { @@ -772,6 +780,14 @@ export class AppHostDataRepository { stream.process = childProcess; }).catch(error => { extensionLogOutputChannel.warn(`Failed to start describe for ${appHostPath}: ${error}`); + // Same hazard as errorCallback above: getAspireCliExecutablePath() can reject + // (CLI missing, permission denied, etc.) without ever firing the spawn error/exit + // callbacks that would normally clean up. Drop the dead entry so the next + // reconcile recreates it instead of leaving a zombie that blocks reconcile + // from re-starting the stream. + if (this._globalDescribeStreams.get(appHostPath) === stream) { + this._globalDescribeStreams.delete(appHostPath); + } }); } @@ -1048,9 +1064,18 @@ export class AppHostDataRepository { return; } - const changed = JSON.stringify(appHosts) !== JSON.stringify(this._appHosts); + // Compare against the previous post-reconcile snapshot rather than the + // raw ps payload. `appHosts` here lacks the `resources` field (ps no longer + // emits it after #17479), while `this._appHosts` was mutated by the prior + // _attachGlobalResourcesToAppHosts call to include resources — a direct + // JSON.stringify compare would always report `changed` once any stream + // produced resources, triggering spurious _onDidChangeData.fire() calls. + const previousSnapshot = this._appHostsSnapshot; this._appHosts = appHosts; this._reconcileGlobalDescribes(); + const nextSnapshot = JSON.stringify(this._appHosts); + const changed = nextSnapshot !== previousSnapshot; + this._appHostsSnapshot = nextSnapshot; if (this._loadingGlobal) { this._loadingGlobal = false;