From ca918131af2a7abf1fff8905c6e7866141f4cbc2 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 25 Aug 2026 10:27:12 -0700 Subject: [PATCH 01/31] Warn when Aspire CLI is outdated Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- extension/loc/xlf/aspire-vscode.xlf | 6 + extension/package.nls.json | 2 + extension/src/data/AppHostDataRepository.ts | 16 ++- extension/src/extension.ts | 20 ++- extension/src/loc/strings.ts | 2 + .../cliPathRejectionNotification.e2e.test.ts | 48 ++++++- extension/src/test-e2e/helpers/fixtures.ts | 16 +++ .../src/test/appHostDataRepository.test.ts | 23 ++++ extension/src/test/configInfoProvider.test.ts | 62 +++++++++ .../src/test/outdatedCliNotifier.test.ts | 123 ++++++++++++++++++ extension/src/test/workspace.test.ts | 25 +++- extension/src/utils/configInfoProvider.ts | 78 +++++++++-- extension/src/utils/outdatedCliNotifier.ts | 71 ++++++++++ extension/src/utils/workspace.ts | 15 +++ 14 files changed, 487 insertions(+), 20 deletions(-) create mode 100644 extension/src/test/outdatedCliNotifier.test.ts create mode 100644 extension/src/utils/outdatedCliNotifier.ts diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index eb20267163b..8b924ef49cc 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -58,6 +58,9 @@ Aspire CLI is not available on PATH. Please install it and restart VS Code. + + Aspire CLI {0} is older than {1}. Update it to avoid a known AppHost startup failure in VS Code. + Aspire Extension @@ -1003,6 +1006,9 @@ Unable to add folder to workspace: {0} + + Update Aspire CLI + Update Aspire CLI diff --git a/extension/package.nls.json b/extension/package.nls.json index 887b1e468a8..55868f6dadf 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -86,6 +86,8 @@ "aspire-vscode.strings.aspireCliPathEnvironmentDescription": "Forwards aspire.aspireCliExecutablePath as AspireCliPath so MSBuild bundle resolution and integrated terminals use the configured Aspire CLI.", "aspire-vscode.strings.configuredCliPathRejected": "The configured Aspire CLI path could not be used, so a different Aspire CLI is running instead: {0}. Check that aspire.aspireCliExecutablePath points to the aspire executable itself and that it runs.", "aspire-vscode.strings.configuredCliPathRejectedOpenSetting": "Open Setting", + "aspire-vscode.strings.outdatedAspireCliWarning": "Aspire CLI {0} is older than {1}. Update it to avoid a known AppHost startup failure in VS Code.", + "aspire-vscode.strings.updateAspireCliAction": "Update Aspire CLI", "aspire-vscode.strings.installCliPlaceholder": "Select how to install the Aspire CLI", "aspire-vscode.strings.installCliViewAllOptions": "View all installation options", "aspire-vscode.strings.installCliViewAllOptionsDescription": "Open the installation guide (includes the install script)", diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index 8e247453a06..a83b38f9dbb 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -147,7 +147,12 @@ export class AppHostDataRepository { private _disposed = false; private readonly _cliRunner: AppHostCliRunner; - constructor(private readonly _terminalProvider: AspireTerminalProvider, appHostDiscoveryService?: AppHostDiscoveryService, configInfoProvider?: ConfigInfoProvider) { + constructor( + private readonly _terminalProvider: AspireTerminalProvider, + appHostDiscoveryService?: AppHostDiscoveryService, + configInfoProvider?: ConfigInfoProvider, + private readonly _onDataActivated?: () => void, + ) { this._cliRunner = new AppHostCliRunner(_terminalProvider); this._psPoller = new AppHostPsPoller( _terminalProvider, @@ -286,6 +291,9 @@ export class AppHostDataRepository { if (this._dataActive) { this._hasEverBeenDataActive = true; } + if (becameDataActive) { + this._onDataActivated?.(); + } this._syncPolling(resumedFromInactive); } @@ -299,6 +307,9 @@ export class AppHostDataRepository { const becameDataActive = !wasDataActive && this._dataActive; const resumedFromInactive = becameDataActive && this._hasEverBeenDataActive; this._hasEverBeenDataActive = true; + if (becameDataActive) { + this._onDataActivated?.(); + } this._syncPolling(resumedFromInactive); let disposed = false; @@ -338,6 +349,9 @@ export class AppHostDataRepository { if (this._dataActive) { this._hasEverBeenDataActive = true; } + if (becameDataActive) { + this._onDataActivated?.(); + } // Re-scope the displayed AppHosts against the new open-tab set right away. this._handlePsSnapshot(this._appHosts, { force: true }); this._syncPolling(resumedFromInactive); diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 8a17c9aeaea..8a6018c4452 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -12,7 +12,7 @@ import AspireDcpServer from './dcp/AspireDcpServer'; import { TestRunSessionManager } from './dcp/TestRunSessionManager'; import { AspireTerminalProvider } from './utils/AspireTerminalProvider'; import { MessageConnection } from 'vscode-jsonrpc'; -import { checkForExistingAppHostPathInWorkspace } from './utils/workspace'; +import { checkForExistingAppHostPathInWorkspace, onDidResolveCliForOperation } from './utils/workspace'; import { AspireEditorCommandProvider } from './editor/AspireEditorCommandProvider'; import { AspirePackageRestoreProvider } from './utils/AspirePackageRestoreProvider'; import { AspireAppHostTreeProvider } from './views/AspireAppHostTreeProvider'; @@ -38,6 +38,7 @@ import { registerCliCommands } from './activation/registerCliCommands'; import { registerTreeViewCommands } from './activation/registerTreeViewCommands'; import { registerCodeLensCommands } from './activation/registerCodeLensCommands'; import { initializeHotReloadAdvisory } from './debugger/hotReload'; +import { OutdatedCliNotifier } from './utils/outdatedCliNotifier'; let aspireExtensionContext = new AspireExtensionContext(); @@ -101,6 +102,13 @@ export async function activate(context: vscode.ExtensionContext) { terminalProvider.closeAllOpenAspireTerminals(); const configInfoProvider = new ConfigInfoProvider(terminalProvider); + const outdatedCliNotifier = new OutdatedCliNotifier(configInfoProvider); + context.subscriptions.push(outdatedCliNotifier); + context.subscriptions.push(onDidResolveCliForOperation(({ target, cliPath }) => { + void outdatedCliNotifier.notifyIfOutdated(target, cliPath).catch(error => { + extensionLogOutputChannel.warn(`Unable to check Aspire CLI version: ${String(error)}`); + }); + })); const appHostDiscoveryService = new AppHostDiscoveryService(terminalProvider, configInfoProvider); context.subscriptions.push(appHostDiscoveryService); @@ -119,7 +127,15 @@ export async function activate(context: vscode.ExtensionContext) { const cliCommandRegistrations = registerCliCommands(terminalProvider, editorCommandProvider, configInfoProvider); // Aspire panel - running app hosts tree view - const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService, configInfoProvider); + const dataRepository = new AppHostDataRepository( + terminalProvider, + appHostDiscoveryService, + configInfoProvider, + () => { + void outdatedCliNotifier.notifyForActiveCliTargets().catch(error => { + extensionLogOutputChannel.warn(`Unable to check Aspire CLI version: ${String(error)}`); + }); + }); appHostLaunchService.setEditorSessionProvider(() => aspireExtensionContext.aspireDebugSessions); appHostLaunchService.setRunningAppHostProvider(async token => { const appHosts = await dataRepository.fetchRunningAppHostsOnce(token); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 083df352649..d9396eb33ce 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -185,6 +185,8 @@ export const failedToGetConfigInfo = (exitCode: number) => vscode.l10n.t('Failed export const failedToParseConfigInfo = (error: any) => vscode.l10n.t('Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); export const errorGettingConfigInfo = (error: any) => vscode.l10n.t('Error getting Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); export const configInfoTimedOut = (seconds: number) => vscode.l10n.t('Aspire config info timed out after {0} seconds.', seconds); +export const outdatedAspireCliWarning = (version: string, minimumVersion: string) => vscode.l10n.t('Aspire CLI {0} is older than {1}. Update it to avoid a known AppHost startup failure in VS Code.', version, minimumVersion); +export const updateAspireCliAction = vscode.l10n.t({ message: 'Update Aspire CLI', comment: 'Button label that updates the installed Aspire CLI.' }); export const invalidLaunchConfiguration = (projectPath: string) => vscode.l10n.t('Invalid launch configuration for {0}.', projectPath); export const browserDisplayName = (url: string) => vscode.l10n.t('Browser: {0}', url); export const browserLabel = vscode.l10n.t('Browser'); diff --git a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts index fd07af7c146..95085ed62d7 100644 --- a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts +++ b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts @@ -1,34 +1,45 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; -import { waitForRepositoryIdle, waitForWorkspaceAppHost } from './helpers/assertions'; +import { getCommandInvocationCount, getTerminalCommandCount, waitForCommandOutcome, waitForRepositoryIdle, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; import { + executeE2eControlCommand, getCliWrapperInvocationCount, restoreE2eCliPathForE2E, restoreWorkspaceCliPath, runE2eTeardown, setE2eCliPathForE2E, + setTerminalCommandExecutionSuppressedForE2E, touchPrimaryAppHostProject, waitForCliWrapperInvocation, + writeOutdatedCliWarningWrapper, writeTrackedStreamingDiscoveryCliWrapper, writeWorkspaceCliPath, } from './helpers/fixtures'; import { getWorkspaceRoot } from './helpers/paths'; import { VSBrowser } from './helpers/extester'; -import { openAspireView, waitForNotificationMessage, waitForWorkbenchText } from './helpers/vscode'; +import { executeCommandFromPalette, openAspireView, waitForNotificationMessage, waitForWorkbenchText } from './helpers/vscode'; // Mirrors configuredCliPathRejected in src/loc/strings.ts. const rejectionNotificationText = 'The configured Aspire CLI path could not be used'; const openSettingActionText = 'Open Setting'; +const outdatedCliWarningText = 'Aspire CLI 13.4.9 is older than 13.5.0'; +const updateCliActionText = 'Update Aspire CLI'; suite('Configured CLI path rejection E2E', function () { this.timeout(300000); + let cleanupOutdatedCliWrapper: (() => void) | undefined; + teardown(async () => { await runE2eTeardown([ + () => executeCommandFromPalette('workbench.view.explorer'), + () => setTerminalCommandExecutionSuppressedForE2E(false), () => restoreE2eCliPathForE2E(), () => restoreWorkspaceCliPath(), + () => cleanupOutdatedCliWrapper?.(), ], 'Configured CLI path rejection E2E teardown failed.'); + cleanupOutdatedCliWrapper = undefined; }); test('resolves a configured directory that contains the aspire executable', async () => { @@ -92,4 +103,37 @@ suite('Configured CLI path rejection E2E', function () { assert.ok(settingsText.length > 0, 'The Aspire CLI executable path setting was not shown.'); }); + + test('warns when active Aspire use resolves an outdated CLI and routes the update action', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + await executeCommandFromPalette('workbench.view.explorer'); + const wrapper = writeOutdatedCliWarningWrapper(); + cleanupOutdatedCliWrapper = wrapper.cleanup; + await setE2eCliPathForE2E(wrapper.cliPath); + await setTerminalCommandExecutionSuppressedForE2E(true); + + await openAspireView(); + const beforeOpenTerminal = getCommandInvocationCount('aspire-vscode.openTerminal'); + await executeE2eControlCommand({ + name: 'executeAspireCommand', + commandId: 'aspire-vscode.openTerminal', + }); + await waitForCommandOutcome('aspire-vscode.openTerminal', 'success', 60_000, beforeOpenTerminal); + + const notification = await waitForNotificationMessage(outdatedCliWarningText, 60_000); + const message = await notification.getMessage(); + await VSBrowser.instance.takeScreenshot('outdated-aspire-cli-warning').catch(() => undefined); + assert.ok(message.includes(outdatedCliWarningText), `Unexpected warning message: ${message}`); + + const beforeTerminalCommand = getTerminalCommandCount(); + await notification.takeAction(updateCliActionText); + const terminalCommand = await waitForTerminalCommand( + event => event.executionSuppressed && event.subcommand === 'update --self', + 'outdated CLI update action', + 60_000, + beforeTerminalCommand); + + assert.strictEqual(terminalCommand.executionSuppressed, true); + }); }); diff --git a/extension/src/test-e2e/helpers/fixtures.ts b/extension/src/test-e2e/helpers/fixtures.ts index 00811894619..f5745037b5a 100644 --- a/extension/src/test-e2e/helpers/fixtures.ts +++ b/extension/src/test-e2e/helpers/fixtures.ts @@ -442,6 +442,22 @@ export function writeTrackedStreamingDiscoveryCliWrapper(delayMs = 4_000, initia return { cliPath, invocationLogPath }; } +export function writeOutdatedCliWarningWrapper(version = '13.4.9'): { cliPath: string; cleanup: () => void } { + const name = 'aspire-outdated-cli-warning'; + const cliPath = writeCliWrapper(name, { + versionOutput: version, + }); + const scriptPath = path.join(path.dirname(cliPath), `${name}.js`); + + return { + cliPath, + cleanup: () => { + removePath(cliPath, { force: true }); + removePath(scriptPath, { force: true }); + }, + }; +} + export function getCliWrapperInvocationCount(invocationLogPath: string): number { if (!fs.existsSync(invocationLogPath)) { return 0; diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index ce0764f33e1..45ad06d095f 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -6213,6 +6213,29 @@ suite('AppHostDataRepository global polling', () => { repository.dispose(); }); + test('notifies only when Aspire data sources become active', () => { + let activationCount = 0; + const repository = new AppHostDataRepository( + terminalProvider, + undefined, + undefined, + () => activationCount++); + + repository.activate(); + assert.strictEqual(activationCount, 0); + + repository.setPanelVisible(true); + repository.setAppHostFilesOpen(['/workspace/AppHost.csproj']); + assert.strictEqual(activationCount, 1); + + repository.setPanelVisible(false); + repository.setAppHostFilesOpen([]); + repository.setPanelVisible(true); + assert.strictEqual(activationCount, 2); + + repository.dispose(); + }); + test('global ps follow and one-shot refresh resolve with the window target', async () => { const repository = new AppHostDataRepository(terminalProvider); diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts index 7a0b09014c4..00555059a9f 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -267,6 +267,68 @@ suite('configInfoProvider tests', () => { assert.deepStrictEqual(spawnStub.secondCall.args[2], ['config', 'info', '--json']); }); + test('getCliVersionStatus enforces the 13.5 stable boundary without running config info', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const versions = [ + ['13.4.99', 'unsupported'], + ['13.5.0-preview.1.12345.6', 'unsupported'], + ['13.5.0', 'supported'], + ['13.5.1+abcdef', 'supported'], + ['14.0.0-preview.1', 'supported'], + ] as const; + let versionIndex = 0; + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { + assert.strictEqual(command, '/exact/aspire'); + assert.deepStrictEqual(args, ['--version']); + options?.stdoutCallback?.(`${versions[versionIndex++][0]}\n`); + options?.exitCallback?.(0); + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + for (const [version, expectedStatus] of versions) { + assert.deepStrictEqual( + await provider.getCliVersionStatus('13.5.0', { cliPath: '/exact/aspire' }), + { + cliPath: '/exact/aspire', + version, + status: expectedStatus, + }); + } + + assert.strictEqual(spawnStub.callCount, versions.length); + for (const call of spawnStub.getCalls()) { + assert.strictEqual(call.args[3]?.createProcessGroup, true); + assert.strictEqual(call.args[3]?.noExtensionVariables, true); + } + }); + + test('getCliVersionStatus keeps failed and malformed probes silent', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + let attempt = 0; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + if (attempt++ === 0) { + options?.exitCallback?.(1); + } else { + options?.stdoutCallback?.('Aspire CLI 13.4.0'); + options?.exitCallback?.(0); + } + return {} as ChildProcessWithoutNullStreams; + }); + const showErrorMessage = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const provider = new ConfigInfoProvider(terminalProvider); + + assert.strictEqual(await provider.getCliVersionStatus('13.5.0', { cliPath: '/exact/aspire' }), null); + assert.strictEqual(await provider.getCliVersionStatus('13.5.0', { cliPath: '/exact/aspire' }), null); + assert.strictEqual(showErrorMessage.callCount, 0); + }); + test('getCapabilityStatus uses advertised capabilities before the minimum-version fallback', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => '/unused/aspire', diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts new file mode 100644 index 00000000000..f02db90207f --- /dev/null +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -0,0 +1,123 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as strings from '../loc/strings'; +import { CliVersionStatus, CliVersionStatusOptions } from '../utils/configInfoProvider'; +import { windowCliPathTarget } from '../utils/cliPathVariables'; +import { minimumSupportedAspireCliVersion, OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier'; + +suite('outdatedCliNotifier', () => { + class FakeVersionProvider { + result: CliVersionStatus | null = null; + readonly calls: Array<{ minimumVersion: string; options?: CliVersionStatusOptions }> = []; + + async getCliVersionStatus(minimumVersion: string, options?: CliVersionStatusOptions): Promise { + this.calls.push({ minimumVersion, options }); + return this.result; + } + } + + class FakeSurface implements OutdatedCliNotificationSurface { + readonly warnings: Array<{ message: string; actions: string[] }> = []; + readonly commands: string[] = []; + selection: string | undefined; + + showWarning(message: string, ...actions: string[]): Thenable { + this.warnings.push({ message, actions }); + return Promise.resolve(this.selection); + } + + executeCommand(command: string): Thenable { + this.commands.push(command); + return Promise.resolve(undefined); + } + } + + function createNotifier(): { + notifier: OutdatedCliNotifier; + versionProvider: FakeVersionProvider; + surface: FakeSurface; + } { + const versionProvider = new FakeVersionProvider(); + const surface = new FakeSurface(); + return { + notifier: new OutdatedCliNotifier(versionProvider, surface), + versionProvider, + surface, + }; + } + + test('does not warn for supported or unavailable CLI versions', async () => { + const { notifier, versionProvider, surface } = createNotifier(); + versionProvider.result = { + cliPath: '/cli/aspire', + version: '13.5.0', + status: 'supported', + }; + + await notifier.notifyIfOutdated(windowCliPathTarget); + versionProvider.result = null; + await notifier.notifyIfOutdated(windowCliPathTarget); + + assert.deepStrictEqual(surface.warnings, []); + assert.deepStrictEqual( + versionProvider.calls.map(call => call.minimumVersion), + [minimumSupportedAspireCliVersion, minimumSupportedAspireCliVersion]); + notifier.dispose(); + }); + + test('warns only once for the same resolved executable and version', async () => { + const { notifier, versionProvider, surface } = createNotifier(); + versionProvider.result = { + cliPath: '/cli/aspire', + version: '13.4.9', + status: 'unsupported', + }; + + await notifier.notifyIfOutdated(windowCliPathTarget); + await notifier.notifyIfOutdated(windowCliPathTarget); + await notifier.notifyIfOutdated(windowCliPathTarget); + + assert.strictEqual(surface.warnings.length, 1); + assert.strictEqual( + surface.warnings[0].message, + 'Aspire CLI 13.4.9 is older than 13.5.0. Update it to avoid a known AppHost startup failure in VS Code.'); + notifier.dispose(); + }); + + test('warns for distinct CLI paths and replacements at the same path', async () => { + const { notifier, versionProvider, surface } = createNotifier(); + const outdatedVersions = [ + { cliPath: '/cli/first/aspire', version: '13.4.8' }, + { cliPath: '/cli/second/aspire', version: '13.4.8' }, + { cliPath: '/cli/first/aspire', version: '13.4.9' }, + ]; + + for (const version of outdatedVersions) { + versionProvider.result = { ...version, status: 'unsupported' }; + await notifier.notifyIfOutdated(windowCliPathTarget, version.cliPath); + } + + assert.strictEqual(surface.warnings.length, outdatedVersions.length); + assert.deepStrictEqual( + surface.warnings.map(warning => warning.message), + outdatedVersions.map(version => + `Aspire CLI ${version.version} is older than 13.5.0. Update it to avoid a known AppHost startup failure in VS Code.`)); + notifier.dispose(); + }); + + test('invokes the existing update-self command when the action is selected', async () => { + const { notifier, versionProvider, surface } = createNotifier(); + versionProvider.result = { + cliPath: '/cli/aspire', + version: '13.4.9', + status: 'unsupported', + }; + surface.selection = strings.updateAspireCliAction; + + await notifier.notifyIfOutdated(windowCliPathTarget); + + assert.deepStrictEqual(surface.warnings[0].actions, [strings.updateAspireCliAction]); + assert.deepStrictEqual(surface.commands, ['aspire-vscode.updateSelf']); + notifier.dispose(); + }); +}); diff --git a/extension/src/test/workspace.test.ts b/extension/src/test/workspace.test.ts index 6b4d33980e9..21001c88f1a 100644 --- a/extension/src/test/workspace.test.ts +++ b/extension/src/test/workspace.test.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { yesLabel } from '../loc/strings'; -import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace'; +import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles, onDidResolveCliForOperation } from '../utils/workspace'; import { AppHostDiscoveryService, getWorkspaceAppHostProjectSearchResult } from '../utils/appHostDiscovery'; import { getAppHostDiscoveryExcludeGlob } from '../utils/workspaceFileSearch'; import * as cliPathModule from '../utils/cliPath'; @@ -52,6 +52,29 @@ suite('utils/workspace tests', () => { assert.ok(tryExecuteCliStub.calledOnceWithExactly('/repo/a/bin/aspire')); assert.strictEqual(resolveCliPathStub.called, false); }); + + test('reports the exact CLI selected for an active command or debug operation', async () => { + const target = workspaceFolderCliPathTarget(createWorkspaceFolder('a', '/repo/a')); + sandbox.stub(cliPathModule, 'resolveCliPath').resolves({ + cliPath: '/repo/a/bin/aspire', + available: true, + source: 'configured', + }); + const resolutions: Array<{ target: typeof target; cliPath: string }> = []; + const disposable = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); + + try { + await checkCliAvailableOrRedirect('debug_gate', target); + + assert.deepStrictEqual(resolutions, [{ + target, + cliPath: '/repo/a/bin/aspire', + }]); + } + finally { + disposable.dispose(); + } + }); }); test('getCommonExcludeGlob returns valid glob pattern', () => { diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index eac36631bc7..3c3e495e645 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -58,7 +58,22 @@ export interface ConfigInfoOptions { timeoutMs?: number; } +export interface CliVersionStatusOptions { + cliPath?: string; + cancellationToken?: vscode.CancellationToken; + /** The resolution scope to use when `cliPath` is not already known. Defaults to the window scope. */ + target?: CliPathResolutionTarget; + timeoutMs?: number; +} + +export interface CliVersionStatus { + cliPath: string; + version: string; + status: Exclude; +} + interface CliVersion { + value: string; major: number; minor: number; patch: number; @@ -188,6 +203,35 @@ export class ConfigInfoProvider { return await this.getCapabilityStatus(capability, options) === 'supported'; } + /** + * Compares the exact resolved Aspire CLI against a minimum stable version. The bounded + * `aspire --version` probe is locale-independent and intentionally uncached so replacing an + * executable in place is observed on the next check. Probe failures return `null` without + * showing an error notification. + */ + async getCliVersionStatus(minimumVersion: string, options?: CliVersionStatusOptions): Promise { + const parsedMinimumVersion = parseCliVersion(minimumVersion); + if (!parsedMinimumVersion) { + extensionLogOutputChannel.warn(`Unable to probe Aspire CLI version: invalid minimum version '${minimumVersion}'.`); + return null; + } + + const target = options?.target ?? windowCliPathTarget; + const startTime = Date.now(); + const timeoutMs = Math.min(options?.timeoutMs ?? cliVersionProbeTimeoutMs, cliVersionProbeTimeoutMs); + const cliPath = options?.cliPath ?? await this._resolveCliPath(true, target, options?.cancellationToken); + if (!cliPath || options?.cancellationToken?.isCancellationRequested) { + return null; + } + + const remainingTimeoutMs = timeoutMs - (Date.now() - startTime); + if (remainingTimeoutMs <= 0) { + return null; + } + + return await this._getCliVersionStatus(cliPath, parsedMinimumVersion, remainingTimeoutMs, options?.cancellationToken); + } + /** * Distinguishes a successful probe of an older CLI from a probe that could not complete. * Callers that must honor an explicit capability-dependent choice cannot safely treat both @@ -232,23 +276,24 @@ export class ConfigInfoProvider { return 'unavailable'; } - return await this._getCliMinimumVersionStatus(cliPath, minimumVersion, remainingTimeoutMs, options.cancellationToken); + return (await this._getCliVersionStatus(cliPath, minimumVersion, remainingTimeoutMs, options.cancellationToken))?.status + ?? 'unavailable'; } - private _getCliMinimumVersionStatus( + private _getCliVersionStatus( cliPath: string, minimumVersion: CliVersion, timeoutMs: number, cancellationToken?: vscode.CancellationToken, - ): Promise { - return new Promise((resolve) => { + ): Promise { + return new Promise((resolve) => { let childProcess: ChildProcessWithoutNullStreams | undefined; let output = ''; let outputTooLong = false; let settled = false; let timeout: ReturnType | undefined; let cancellation: vscode.Disposable | undefined; - const settle = (result: CapabilityStatus) => { + const settle = (result: CliVersionStatus | null) => { if (settled) { return; } @@ -266,15 +311,15 @@ export class ConfigInfoProvider { } extensionLogOutputChannel.warn(`Unable to probe Aspire CLI version: ${String(error)}`); - settle('unavailable'); + settle(null); }; if (cancellationToken?.isCancellationRequested) { - settle('unavailable'); + settle(null); return; } timeout = setTimeout(() => { - settle('unavailable'); + settle(null); if (childProcess) { void terminateCliProcess(childProcess, 'timed-out Aspire CLI version probe').catch(error => { extensionLogOutputChannel.error(`Failed to terminate timed-out Aspire CLI version probe: ${String(error)}`); @@ -282,7 +327,7 @@ export class ConfigInfoProvider { } }, Math.min(timeoutMs, cliVersionProbeTimeoutMs)); cancellation = cancellationToken?.onCancellationRequested(() => { - settle('unavailable'); + settle(null); if (childProcess) { void terminateCliProcess(childProcess, 'cancelled Aspire CLI version probe').catch(error => { extensionLogOutputChannel.error(`Failed to terminate cancelled Aspire CLI version probe: ${String(error)}`); @@ -303,13 +348,13 @@ export class ConfigInfoProvider { }, exitCallback: code => { if (code !== 0 || outputTooLong) { - settle('unavailable'); + settle(null); return; } const version = parseCliVersion(output); if (!version) { - settle('unavailable'); + settle(null); return; } @@ -318,9 +363,13 @@ export class ConfigInfoProvider { // introduced the option, so treat it as known unsupported. Prerelease/dev // builds with a higher numeric core remain supported because the feature // already exists on that later release line. - settle(comparison > 0 || (comparison === 0 && (!version.isPrerelease || minimumVersion.isPrerelease)) - ? 'supported' - : 'unsupported'); + settle({ + cliPath, + version: version.value, + status: comparison > 0 || (comparison === 0 && (!version.isPrerelease || minimumVersion.isPrerelease)) + ? 'supported' + : 'unsupported', + }); }, errorCallback: reportUnavailable, noExtensionVariables: true, @@ -601,6 +650,7 @@ function parseCliVersion(value: string): CliVersion | undefined { } return { + value: normalized, major: Number.parseInt(match[1], 10), minor: Number.parseInt(match[2], 10), patch: Number.parseInt(match[3], 10), diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts new file mode 100644 index 00000000000..93a7feb489f --- /dev/null +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -0,0 +1,71 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import * as strings from '../loc/strings'; +import { CliVersionStatusOptions, ConfigInfoProvider } from './configInfoProvider'; +import { CliPathResolutionTarget, windowCliPathTarget, workspaceFolderCliPathTarget } from './cliPathVariables'; +import { getComparisonKey } from './paths/comparison'; + +export const minimumSupportedAspireCliVersion = '13.5.0'; +const updateAspireCliCommand = 'aspire-vscode.updateSelf'; + +type CliVersionProvider = Pick; + +export interface OutdatedCliNotificationSurface { + showWarning(message: string, ...actions: string[]): Thenable; + executeCommand(command: string): Thenable; +} + +const defaultSurface: OutdatedCliNotificationSurface = { + showWarning: (message, ...actions) => vscode.window.showWarningMessage(message, ...actions), + executeCommand: command => vscode.commands.executeCommand(command), +}; + +/** + * Warns when an Aspire feature actively selects a CLI older than the release that fixed AppHost + * startup issue #17354. Checks are session-scoped and best effort: an unavailable version probe + * remains silent, while replacing a CLI in place with a different version is observed. + */ +export class OutdatedCliNotifier implements vscode.Disposable { + private readonly _notifiedCliVersions = new Set(); + + constructor( + private readonly _versionProvider: CliVersionProvider, + private readonly _surface: OutdatedCliNotificationSurface = defaultSurface, + ) { + } + + async notifyIfOutdated(target: CliPathResolutionTarget, cliPath?: string): Promise { + const options: CliVersionStatusOptions = { target, cliPath }; + const result = await this._versionProvider.getCliVersionStatus(minimumSupportedAspireCliVersion, options); + if (result?.status !== 'unsupported') { + return; + } + + const notificationKey = `${getComparisonKey(path.normalize(result.cliPath))}\u0000${result.version}`; + if (this._notifiedCliVersions.has(notificationKey)) { + return; + } + + // Mark the executable/version before showing UI so concurrent folder probes cannot create + // duplicate notifications while the first warning is still awaiting user input. + this._notifiedCliVersions.add(notificationKey); + const selection = await this._surface.showWarning( + strings.outdatedAspireCliWarning(result.version, minimumSupportedAspireCliVersion), + strings.updateAspireCliAction); + if (selection === strings.updateAspireCliAction) { + await this._surface.executeCommand(updateAspireCliCommand); + } + } + + async notifyForActiveCliTargets(): Promise { + const targets = [ + windowCliPathTarget, + ...(vscode.workspace.workspaceFolders ?? []).map(workspaceFolderCliPathTarget), + ]; + await Promise.all(targets.map(target => this.notifyIfOutdated(target))); + } + + dispose(): void { + this._notifiedCliVersions.clear(); + } +} diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index 6fc22680429..31fb0e87197 100644 --- a/extension/src/utils/workspace.ts +++ b/extension/src/utils/workspace.ts @@ -245,6 +245,19 @@ async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearch extensionLogOutputChannel.info(`Successfully set AppHost path to: ${appHostToUse} in ${settingsFileLocation.fsPath}`); } +export interface CliOperationResolution { + target: CliPathResolutionTarget; + cliPath: string; +} + +const cliOperationResolutionEmitter = new vscode.EventEmitter(); + +/** + * Fires only after a command or debug operation has resolved and validated the CLI it will use. + * Activation-time environment synchronization and eager workspace discovery do not raise this event. + */ +export const onDidResolveCliForOperation = cliOperationResolutionEmitter.event; + /** * Checks if the Aspire CLI is available. If not found on PATH, it checks the default * installation directory and updates the VS Code setting accordingly. @@ -282,6 +295,8 @@ export async function checkCliAvailableOrRedirect( }); if (result.available) { + cliOperationResolutionEmitter.fire({ target, cliPath: result.cliPath }); + // Show informational message if CLI was found at default path (not on PATH) if (result.source === 'default-install') { extensionLogOutputChannel.info(`Using Aspire CLI from default install location: ${result.cliPath}`); From 9d30114495c20a73d87db6255785ef36d0f51cc1 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 25 Aug 2026 10:43:01 -0700 Subject: [PATCH 02/31] Route outdated CLI updates to the warned executable Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- extension/loc/xlf/aspire-vscode.xlf | 2 +- extension/package.nls.json | 2 +- .../src/activation/registerCliCommands.ts | 10 +++++-- extension/src/commands/update.ts | 9 ++++-- extension/src/data/AppHostDataRepository.ts | 17 ++++++----- extension/src/extension.ts | 13 ++++----- extension/src/loc/strings.ts | 2 +- .../cliPathRejectionNotification.e2e.test.ts | 2 +- .../src/test/appHostDataRepository.test.ts | 8 ++---- .../src/test/outdatedCliNotifier.test.ts | 28 ++++++++++++------- .../src/test/registerCliCommands.test.ts | 13 +++++++++ extension/src/utils/outdatedCliNotifier.ts | 6 ++-- 12 files changed, 69 insertions(+), 43 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 8b924ef49cc..3c453959323 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -59,7 +59,7 @@ Aspire CLI is not available on PATH. Please install it and restart VS Code. - Aspire CLI {0} is older than {1}. Update it to avoid a known AppHost startup failure in VS Code. + Aspire CLI {0} is older than {1}. Update the CLI and the AppHost's Aspire packages to {1} or later to avoid a known startup failure in VS Code. Aspire Extension diff --git a/extension/package.nls.json b/extension/package.nls.json index 55868f6dadf..d1768beea59 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -86,7 +86,7 @@ "aspire-vscode.strings.aspireCliPathEnvironmentDescription": "Forwards aspire.aspireCliExecutablePath as AspireCliPath so MSBuild bundle resolution and integrated terminals use the configured Aspire CLI.", "aspire-vscode.strings.configuredCliPathRejected": "The configured Aspire CLI path could not be used, so a different Aspire CLI is running instead: {0}. Check that aspire.aspireCliExecutablePath points to the aspire executable itself and that it runs.", "aspire-vscode.strings.configuredCliPathRejectedOpenSetting": "Open Setting", - "aspire-vscode.strings.outdatedAspireCliWarning": "Aspire CLI {0} is older than {1}. Update it to avoid a known AppHost startup failure in VS Code.", + "aspire-vscode.strings.outdatedAspireCliWarning": "Aspire CLI {0} is older than {1}. Update the CLI and the AppHost's Aspire packages to {1} or later to avoid a known startup failure in VS Code.", "aspire-vscode.strings.updateAspireCliAction": "Update Aspire CLI", "aspire-vscode.strings.installCliPlaceholder": "Select how to install the Aspire CLI", "aspire-vscode.strings.installCliViewAllOptions": "View all installation options", diff --git a/extension/src/activation/registerCliCommands.ts b/extension/src/activation/registerCliCommands.ts index e65b41e8ab2..04fa6306cad 100644 --- a/extension/src/activation/registerCliCommands.ts +++ b/extension/src/activation/registerCliCommands.ts @@ -28,6 +28,7 @@ import { getCliPathTargetForUri } from '../utils/cliPathVariables'; interface CommandInvocation { readonly target: CliPathResolutionTarget; readonly appHost?: AppHostCommandTarget; + readonly cliPath?: string; } type CommandSource = 'command_palette' | 'tree'; @@ -48,7 +49,12 @@ export function registerCliCommands( const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', terminalProvider, () => publishCommand(editorCommandProvider))); const cliDoCommandRegistration = vscode.commands.registerCommand('aspire-vscode.do', () => tryExecuteCommand('aspire-vscode.do', terminalProvider, (_tp, invocation, cliPath) => doCommand(configInfoProvider, editorCommandProvider, invocation.appHost?.appHostPath, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider, true))); const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, (tp, invocation, cliPath) => updateCommand(tp, editorCommandProvider, invocation.appHost ?? {}, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider))); - const cliUpdateSelfCommandRegistration = vscode.commands.registerCommand('aspire-vscode.updateSelf', () => tryExecuteCommand('aspire-vscode.updateSelf', terminalProvider, updateSelfCommand)); + const cliUpdateSelfCommandRegistration = vscode.commands.registerCommand('aspire-vscode.updateSelf', (target: CliPathResolutionTarget = windowCliPathTarget, cliPath?: string) => + tryExecuteCommand( + 'aspire-vscode.updateSelf', + terminalProvider, + (tp, invocation, resolvedCliPath) => updateSelfCommand(tp, invocation.target, resolvedCliPath || undefined), + async () => ({ target, cliPath }))); const openTerminalCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openTerminal', () => tryExecuteCommand('aspire-vscode.openTerminal', terminalProvider, (tp, invocation, cliPath) => openTerminalCommand(tp, invocation.target, cliPath), selectCommandInvocation)); const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', terminalProvider, configureLaunchJsonCommand)); const settingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.settings', () => tryExecuteCommand('aspire-vscode.settings', terminalProvider, settingsCommand)); @@ -142,7 +148,7 @@ async function tryExecuteCommand( try { await withCommandTelemetry(commandName, async () => { const invocation = await prepareInvocation(); - let cliPath = ''; + let cliPath = invocation.cliPath ?? ''; const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson", "aspire-vscode.updateSelf"]; if (!cliCheckExcludedCommands.includes(commandName)) { if (isE2eBridgeEnabled() && process.env.ASPIRE_EXTENSION_E2E_FORCE_CLI_UNAVAILABLE === 'true') { diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts index 06a96bb76df..c160d5f31c5 100644 --- a/extension/src/commands/update.ts +++ b/extension/src/commands/update.ts @@ -13,6 +13,11 @@ export async function updateCommand( await terminalProvider.sendAspireCommandToAspireTerminal('update', true, appHost.args, { target, cliPath }); } -export async function updateSelfCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('update --self', true, undefined, { target: windowCliPathTarget }); +export async function updateSelfCommand( + terminalProvider: AspireTerminalProvider, + target: CliPathResolutionTarget = windowCliPathTarget, + cliPath?: string, +) { + const options = cliPath ? { target, cliPath } : { target }; + await terminalProvider.sendAspireCommandToAspireTerminal('update --self', true, undefined, options); } diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index a83b38f9dbb..4748ea83707 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -84,6 +84,9 @@ export class AppHostDataRepository { private readonly _onDidChangeData = new vscode.EventEmitter(); readonly onDidChangeData = this._onDidChangeData.event; + private readonly _onDidBecomeDataActive = new vscode.EventEmitter(); + readonly onDidBecomeDataActive = this._onDidBecomeDataActive.event; + // ── Mode / panel state ── private _viewMode: ViewMode = 'workspace'; private _panelVisible = false; @@ -147,12 +150,7 @@ export class AppHostDataRepository { private _disposed = false; private readonly _cliRunner: AppHostCliRunner; - constructor( - private readonly _terminalProvider: AspireTerminalProvider, - appHostDiscoveryService?: AppHostDiscoveryService, - configInfoProvider?: ConfigInfoProvider, - private readonly _onDataActivated?: () => void, - ) { + constructor(private readonly _terminalProvider: AspireTerminalProvider, appHostDiscoveryService?: AppHostDiscoveryService, configInfoProvider?: ConfigInfoProvider) { this._cliRunner = new AppHostCliRunner(_terminalProvider); this._psPoller = new AppHostPsPoller( _terminalProvider, @@ -292,7 +290,7 @@ export class AppHostDataRepository { this._hasEverBeenDataActive = true; } if (becameDataActive) { - this._onDataActivated?.(); + this._onDidBecomeDataActive.fire(); } this._syncPolling(resumedFromInactive); } @@ -308,7 +306,7 @@ export class AppHostDataRepository { const resumedFromInactive = becameDataActive && this._hasEverBeenDataActive; this._hasEverBeenDataActive = true; if (becameDataActive) { - this._onDataActivated?.(); + this._onDidBecomeDataActive.fire(); } this._syncPolling(resumedFromInactive); @@ -350,7 +348,7 @@ export class AppHostDataRepository { this._hasEverBeenDataActive = true; } if (becameDataActive) { - this._onDataActivated?.(); + this._onDidBecomeDataActive.fire(); } // Re-scope the displayed AppHosts against the new open-tab set right away. this._handlePsSnapshot(this._appHosts, { force: true }); @@ -582,6 +580,7 @@ export class AppHostDataRepository { this._psPollerDisposable.dispose(); this._psPoller.dispose(); this._onDidChangeData.dispose(); + this._onDidBecomeDataActive.dispose(); if (this._ownsAppHostDiscoveryService) { this._appHostDiscoveryService.dispose(); } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 8a6018c4452..bb75a455602 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -127,15 +127,12 @@ export async function activate(context: vscode.ExtensionContext) { const cliCommandRegistrations = registerCliCommands(terminalProvider, editorCommandProvider, configInfoProvider); // Aspire panel - running app hosts tree view - const dataRepository = new AppHostDataRepository( - terminalProvider, - appHostDiscoveryService, - configInfoProvider, - () => { - void outdatedCliNotifier.notifyForActiveCliTargets().catch(error => { - extensionLogOutputChannel.warn(`Unable to check Aspire CLI version: ${String(error)}`); - }); + const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService, configInfoProvider); + context.subscriptions.push(dataRepository.onDidBecomeDataActive(() => { + void outdatedCliNotifier.notifyForActiveCliTargets().catch(error => { + extensionLogOutputChannel.warn(`Unable to check Aspire CLI version: ${String(error)}`); }); + })); appHostLaunchService.setEditorSessionProvider(() => aspireExtensionContext.aspireDebugSessions); appHostLaunchService.setRunningAppHostProvider(async token => { const appHosts = await dataRepository.fetchRunningAppHostsOnce(token); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index d9396eb33ce..ff4f745f88f 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -185,7 +185,7 @@ export const failedToGetConfigInfo = (exitCode: number) => vscode.l10n.t('Failed export const failedToParseConfigInfo = (error: any) => vscode.l10n.t('Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); export const errorGettingConfigInfo = (error: any) => vscode.l10n.t('Error getting Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); export const configInfoTimedOut = (seconds: number) => vscode.l10n.t('Aspire config info timed out after {0} seconds.', seconds); -export const outdatedAspireCliWarning = (version: string, minimumVersion: string) => vscode.l10n.t('Aspire CLI {0} is older than {1}. Update it to avoid a known AppHost startup failure in VS Code.', version, minimumVersion); +export const outdatedAspireCliWarning = (version: string, minimumVersion: string) => vscode.l10n.t("Aspire CLI {0} is older than {1}. Update the CLI and the AppHost's Aspire packages to {1} or later to avoid a known startup failure in VS Code.", version, minimumVersion); export const updateAspireCliAction = vscode.l10n.t({ message: 'Update Aspire CLI', comment: 'Button label that updates the installed Aspire CLI.' }); export const invalidLaunchConfiguration = (projectPath: string) => vscode.l10n.t('Invalid launch configuration for {0}.', projectPath); export const browserDisplayName = (url: string) => vscode.l10n.t('Browser: {0}', url); diff --git a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts index 95085ed62d7..aa704e033cc 100644 --- a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts +++ b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts @@ -23,7 +23,7 @@ import { executeCommandFromPalette, openAspireView, waitForNotificationMessage, // Mirrors configuredCliPathRejected in src/loc/strings.ts. const rejectionNotificationText = 'The configured Aspire CLI path could not be used'; const openSettingActionText = 'Open Setting'; -const outdatedCliWarningText = 'Aspire CLI 13.4.9 is older than 13.5.0'; +const outdatedCliWarningText = "Aspire CLI 13.4.9 is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later"; const updateCliActionText = 'Update Aspire CLI'; suite('Configured CLI path rejection E2E', function () { diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 45ad06d095f..2151a53cd85 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -6215,11 +6215,8 @@ suite('AppHostDataRepository global polling', () => { test('notifies only when Aspire data sources become active', () => { let activationCount = 0; - const repository = new AppHostDataRepository( - terminalProvider, - undefined, - undefined, - () => activationCount++); + const repository = new AppHostDataRepository(terminalProvider); + const subscription = repository.onDidBecomeDataActive(() => activationCount++); repository.activate(); assert.strictEqual(activationCount, 0); @@ -6233,6 +6230,7 @@ suite('AppHostDataRepository global polling', () => { repository.setPanelVisible(true); assert.strictEqual(activationCount, 2); + subscription.dispose(); repository.dispose(); }); diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index f02db90207f..2620b418feb 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import * as strings from '../loc/strings'; import { CliVersionStatus, CliVersionStatusOptions } from '../utils/configInfoProvider'; -import { windowCliPathTarget } from '../utils/cliPathVariables'; +import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { minimumSupportedAspireCliVersion, OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier'; suite('outdatedCliNotifier', () => { @@ -18,7 +18,7 @@ suite('outdatedCliNotifier', () => { class FakeSurface implements OutdatedCliNotificationSurface { readonly warnings: Array<{ message: string; actions: string[] }> = []; - readonly commands: string[] = []; + readonly commands: Array<{ command: string; args: unknown[] }> = []; selection: string | undefined; showWarning(message: string, ...actions: string[]): Thenable { @@ -26,8 +26,8 @@ suite('outdatedCliNotifier', () => { return Promise.resolve(this.selection); } - executeCommand(command: string): Thenable { - this.commands.push(command); + executeCommand(command: string, ...args: unknown[]): Thenable { + this.commands.push({ command, args }); return Promise.resolve(undefined); } } @@ -80,7 +80,7 @@ suite('outdatedCliNotifier', () => { assert.strictEqual(surface.warnings.length, 1); assert.strictEqual( surface.warnings[0].message, - 'Aspire CLI 13.4.9 is older than 13.5.0. Update it to avoid a known AppHost startup failure in VS Code.'); + "Aspire CLI 13.4.9 is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later to avoid a known startup failure in VS Code."); notifier.dispose(); }); @@ -101,23 +101,31 @@ suite('outdatedCliNotifier', () => { assert.deepStrictEqual( surface.warnings.map(warning => warning.message), outdatedVersions.map(version => - `Aspire CLI ${version.version} is older than 13.5.0. Update it to avoid a known AppHost startup failure in VS Code.`)); + `Aspire CLI ${version.version} is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later to avoid a known startup failure in VS Code.`)); notifier.dispose(); }); - test('invokes the existing update-self command when the action is selected', async () => { + test('updates the exact workspace CLI that triggered the warning', async () => { const { notifier, versionProvider, surface } = createNotifier(); + const target = workspaceFolderCliPathTarget({ + uri: vscode.Uri.file('/workspace/a'), + name: 'a', + index: 0, + }); versionProvider.result = { - cliPath: '/cli/aspire', + cliPath: '/workspace/a/.aspire/bin/aspire', version: '13.4.9', status: 'unsupported', }; surface.selection = strings.updateAspireCliAction; - await notifier.notifyIfOutdated(windowCliPathTarget); + await notifier.notifyIfOutdated(target); assert.deepStrictEqual(surface.warnings[0].actions, [strings.updateAspireCliAction]); - assert.deepStrictEqual(surface.commands, ['aspire-vscode.updateSelf']); + assert.deepStrictEqual(surface.commands, [{ + command: 'aspire-vscode.updateSelf', + args: [target, '/workspace/a/.aspire/bin/aspire'], + }]); notifier.dispose(); }); }); diff --git a/extension/src/test/registerCliCommands.test.ts b/extension/src/test/registerCliCommands.test.ts index 2a920c45c18..d92b742a982 100644 --- a/extension/src/test/registerCliCommands.test.ts +++ b/extension/src/test/registerCliCommands.test.ts @@ -346,4 +346,17 @@ suite('registerCliCommands', () => { assert.strictEqual(resolveCliPathStub.called, false); assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { target: windowCliPathTarget })); }); + + test('update self uses the pre-resolved workspace CLI supplied by the warning action', async () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + const target = workspaceFolderCliPathTarget(folder); + + await callbacks.get('aspire-vscode.updateSelf')!(target, '/repo/a/.aspire/bin/aspire'); + + assert.strictEqual(resolveCliPathStub.called, false); + assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { + target, + cliPath: '/repo/a/.aspire/bin/aspire', + })); + }); }); \ No newline at end of file diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 93a7feb489f..e7d91273675 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -12,12 +12,12 @@ type CliVersionProvider = Pick; export interface OutdatedCliNotificationSurface { showWarning(message: string, ...actions: string[]): Thenable; - executeCommand(command: string): Thenable; + executeCommand(command: string, ...args: unknown[]): Thenable; } const defaultSurface: OutdatedCliNotificationSurface = { showWarning: (message, ...actions) => vscode.window.showWarningMessage(message, ...actions), - executeCommand: command => vscode.commands.executeCommand(command), + executeCommand: (command, ...args) => vscode.commands.executeCommand(command, ...args), }; /** @@ -53,7 +53,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { strings.outdatedAspireCliWarning(result.version, minimumSupportedAspireCliVersion), strings.updateAspireCliAction); if (selection === strings.updateAspireCliAction) { - await this._surface.executeCommand(updateAspireCliCommand); + await this._surface.executeCommand(updateAspireCliCommand, target, result.cliPath); } } From 7f75bbf811746701ca6bd92a371cecac690bf618 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 25 Aug 2026 11:34:08 -0700 Subject: [PATCH 03/31] Fix outdated CLI warning lifecycle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/activation/registerCliCommands.ts | 21 +++++++- extension/src/commands/deploy.ts | 10 +++- extension/src/commands/publish.ts | 10 +++- .../src/editor/AspireEditorCommandProvider.ts | 8 +-- .../cliPathRejectionNotification.e2e.test.ts | 8 +-- .../src/test/outdatedCliNotifier.test.ts | 50 ++++++++++++++++- .../src/test/registerCliCommands.test.ts | 53 ++++++++++++++++++- extension/src/utils/outdatedCliNotifier.ts | 23 ++++++-- 8 files changed, 164 insertions(+), 19 deletions(-) diff --git a/extension/src/activation/registerCliCommands.ts b/extension/src/activation/registerCliCommands.ts index 04fa6306cad..bbb8f878206 100644 --- a/extension/src/activation/registerCliCommands.ts +++ b/extension/src/activation/registerCliCommands.ts @@ -45,8 +45,16 @@ export function registerCliCommands( // through tryExecuteCommand itself — the delegated-to command owns its own CLI // availability check and telemetry. const createWithAspireCommandRegistration = registerInstrumentedCommand('aspire-vscode.createWithAspire', 'tree', createWithAspireCommand); - const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand('aspire-vscode.deploy', terminalProvider, () => deployCommand(editorCommandProvider))); - const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', terminalProvider, () => publishCommand(editorCommandProvider))); + const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand( + 'aspire-vscode.deploy', + terminalProvider, + (_tp, invocation, cliPath) => deployCommand(editorCommandProvider, getRequiredAppHostPath(invocation), invocation.target, cliPath), + () => selectAppHostCommandInvocation(editorCommandProvider, true))); + const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand( + 'aspire-vscode.publish', + terminalProvider, + (_tp, invocation, cliPath) => publishCommand(editorCommandProvider, getRequiredAppHostPath(invocation), invocation.target, cliPath), + () => selectAppHostCommandInvocation(editorCommandProvider, true))); const cliDoCommandRegistration = vscode.commands.registerCommand('aspire-vscode.do', () => tryExecuteCommand('aspire-vscode.do', terminalProvider, (_tp, invocation, cliPath) => doCommand(configInfoProvider, editorCommandProvider, invocation.appHost?.appHostPath, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider, true))); const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, (tp, invocation, cliPath) => updateCommand(tp, editorCommandProvider, invocation.appHost ?? {}, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider))); const cliUpdateSelfCommandRegistration = vscode.commands.registerCommand('aspire-vscode.updateSelf', (target: CliPathResolutionTarget = windowCliPathTarget, cliPath?: string) => @@ -138,6 +146,15 @@ async function selectAppHostCommandInvocation(editorCommandProvider: AspireEdito return { target, appHost }; } +function getRequiredAppHostPath(invocation: CommandInvocation): string { + const appHostPath = invocation.appHost?.appHostPath; + if (!appHostPath) { + throw new Error(noAppHostInWorkspace); + } + + return appHostPath; +} + async function tryExecuteCommand( commandName: string, terminalProvider: AspireTerminalProvider, diff --git a/extension/src/commands/deploy.ts b/extension/src/commands/deploy.ts index 41116b75fe7..7f714d8473f 100644 --- a/extension/src/commands/deploy.ts +++ b/extension/src/commands/deploy.ts @@ -1,5 +1,11 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; +import { CliPathResolutionTarget } from '../utils/cliPathVariables'; -export async function deployCommand(editorCommandProvider: AspireEditorCommandProvider) { - await editorCommandProvider.tryExecuteDeployAppHost(false); +export async function deployCommand( + editorCommandProvider: AspireEditorCommandProvider, + appHostPath: string, + target: CliPathResolutionTarget, + cliPath: string, +) { + await editorCommandProvider.tryExecuteDeployAppHost(false, appHostPath, target, cliPath); } diff --git a/extension/src/commands/publish.ts b/extension/src/commands/publish.ts index 40eace33ed1..7ce57045570 100644 --- a/extension/src/commands/publish.ts +++ b/extension/src/commands/publish.ts @@ -1,5 +1,11 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; +import { CliPathResolutionTarget } from '../utils/cliPathVariables'; -export async function publishCommand(editorCommandProvider: AspireEditorCommandProvider) { - await editorCommandProvider.tryExecutePublishAppHost(false); +export async function publishCommand( + editorCommandProvider: AspireEditorCommandProvider, + appHostPath: string, + target: CliPathResolutionTarget, + cliPath: string, +) { + await editorCommandProvider.tryExecutePublishAppHost(false, appHostPath, target, cliPath); } diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index 0ee05d1725f..f4fd59d9295 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -129,12 +129,12 @@ export class AspireEditorCommandProvider implements vscode.Disposable { await this.launchAspireDebugSession('run', noDebug, undefined, await this.getAppHostPath(resource, allowWorkspaceFallback)); } - public async tryExecuteDeployAppHost(noDebug: boolean): Promise { - await this.launchAspireDebugSession('deploy', noDebug); + public async tryExecuteDeployAppHost(noDebug: boolean, appHostPath: string, target: CliPathResolutionTarget, cliPath: string): Promise { + await this.launchAspireDebugSession('deploy', noDebug, undefined, appHostPath, target, cliPath); } - public async tryExecutePublishAppHost(noDebug: boolean): Promise { - await this.launchAspireDebugSession('publish', noDebug); + public async tryExecutePublishAppHost(noDebug: boolean, appHostPath: string, target: CliPathResolutionTarget, cliPath: string): Promise { + await this.launchAspireDebugSession('publish', noDebug, undefined, appHostPath, target, cliPath); } public async tryExecuteDoAppHost(noDebug: boolean, doStep?: string, appHostPath?: string, target?: CliPathResolutionTarget, cliPath?: string): Promise { diff --git a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts index aa704e033cc..7c39d7dd059 100644 --- a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts +++ b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts @@ -18,7 +18,7 @@ import { } from './helpers/fixtures'; import { getWorkspaceRoot } from './helpers/paths'; import { VSBrowser } from './helpers/extester'; -import { executeCommandFromPalette, openAspireView, waitForNotificationMessage, waitForWorkbenchText } from './helpers/vscode'; +import { closeAllEditors, openAspireView, waitForNotificationMessage, waitForWorkbenchText } from './helpers/vscode'; // Mirrors configuredCliPathRejected in src/loc/strings.ts. const rejectionNotificationText = 'The configured Aspire CLI path could not be used'; @@ -33,7 +33,7 @@ suite('Configured CLI path rejection E2E', function () { teardown(async () => { await runE2eTeardown([ - () => executeCommandFromPalette('workbench.view.explorer'), + () => closeAllEditors(), () => setTerminalCommandExecutionSuppressedForE2E(false), () => restoreE2eCliPathForE2E(), () => restoreWorkspaceCliPath(), @@ -107,7 +107,6 @@ suite('Configured CLI path rejection E2E', function () { test('warns when active Aspire use resolves an outdated CLI and routes the update action', async () => { await openAspireView(); await waitForRepositoryIdle(); - await executeCommandFromPalette('workbench.view.explorer'); const wrapper = writeOutdatedCliWarningWrapper(); cleanupOutdatedCliWrapper = wrapper.cleanup; await setE2eCliPathForE2E(wrapper.cliPath); @@ -135,5 +134,8 @@ suite('Configured CLI path rejection E2E', function () { beforeTerminalCommand); assert.strictEqual(terminalCommand.executionSuppressed, true); + assert.ok( + terminalCommand.commandLine.includes(wrapper.cliPath), + `Expected update command to use the warned CLI '${wrapper.cliPath}': ${terminalCommand.commandLine}`); }); }); diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index 2620b418feb..2f03532151f 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -8,11 +8,12 @@ import { minimumSupportedAspireCliVersion, OutdatedCliNotificationSurface, Outda suite('outdatedCliNotifier', () => { class FakeVersionProvider { result: CliVersionStatus | null = null; + resultPromise: Promise | undefined; readonly calls: Array<{ minimumVersion: string; options?: CliVersionStatusOptions }> = []; async getCliVersionStatus(minimumVersion: string, options?: CliVersionStatusOptions): Promise { this.calls.push({ minimumVersion, options }); - return this.result; + return await (this.resultPromise ?? this.result); } } @@ -20,10 +21,11 @@ suite('outdatedCliNotifier', () => { readonly warnings: Array<{ message: string; actions: string[] }> = []; readonly commands: Array<{ command: string; args: unknown[] }> = []; selection: string | undefined; + selectionPromise: Promise | undefined; showWarning(message: string, ...actions: string[]): Thenable { this.warnings.push({ message, actions }); - return Promise.resolve(this.selection); + return this.selectionPromise ?? Promise.resolve(this.selection); } executeCommand(command: string, ...args: unknown[]): Thenable { @@ -128,4 +130,48 @@ suite('outdatedCliNotifier', () => { }]); notifier.dispose(); }); + + test('dispose cancels an in-flight version probe and suppresses its warning', async () => { + const { notifier, versionProvider, surface } = createNotifier(); + let resolveProbe!: (result: CliVersionStatus | null) => void; + versionProvider.resultPromise = new Promise(resolve => resolveProbe = resolve); + + const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + const cancellationToken = versionProvider.calls[0].options?.cancellationToken; + assert.ok(cancellationToken); + assert.strictEqual(cancellationToken.isCancellationRequested, false); + + notifier.dispose(); + assert.strictEqual(cancellationToken.isCancellationRequested, true); + resolveProbe({ + cliPath: '/cli/aspire', + version: '13.4.9', + status: 'unsupported', + }); + await notification; + + assert.deepStrictEqual(surface.warnings, []); + assert.deepStrictEqual(surface.commands, []); + }); + + test('dispose while the warning awaits selection suppresses the update action', async () => { + const { notifier, versionProvider, surface } = createNotifier(); + versionProvider.result = { + cliPath: '/cli/aspire', + version: '13.4.9', + status: 'unsupported', + }; + let resolveSelection!: (selection: string | undefined) => void; + surface.selectionPromise = new Promise(resolve => resolveSelection = resolve); + + const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(surface.warnings.length, 1); + + notifier.dispose(); + resolveSelection(strings.updateAspireCliAction); + await notification; + + assert.deepStrictEqual(surface.commands, []); + }); }); diff --git a/extension/src/test/registerCliCommands.test.ts b/extension/src/test/registerCliCommands.test.ts index d92b742a982..e576b072cd3 100644 --- a/extension/src/test/registerCliCommands.test.ts +++ b/extension/src/test/registerCliCommands.test.ts @@ -11,8 +11,9 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvid import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import * as cliPathModule from '../utils/cliPath'; import { ConfigInfoProvider } from '../utils/configInfoProvider'; -import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { CliPathResolutionTarget, windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { CommandInvocationEvent, onDidInvokeCommand } from '../utils/telemetry'; +import { onDidResolveCliForOperation } from '../utils/workspace'; import { createWorkspaceFolder, removeDirectorySafely } from './testHelpers'; suite('registerCliCommands', () => { let sandbox: sinon.SinonSandbox; @@ -28,6 +29,8 @@ suite('registerCliCommands', () => { let getAppHostPathStub: sinon.SinonStub; let tryExecuteDoAppHostStub: sinon.SinonStub; let tryExecuteRunAppHostStub: sinon.SinonStub; + let tryExecuteDeployAppHostStub: sinon.SinonStub; + let tryExecutePublishAppHostStub: sinon.SinonStub; let editorCommandProvider: AspireEditorCommandProvider; let tempDir: string; @@ -60,10 +63,14 @@ suite('registerCliCommands', () => { getAppHostPathStub = sinon.stub().resolves(null); tryExecuteDoAppHostStub = sinon.stub().resolves(); tryExecuteRunAppHostStub = sinon.stub().resolves(); + tryExecuteDeployAppHostStub = sinon.stub().resolves(); + tryExecutePublishAppHostStub = sinon.stub().resolves(); editorCommandProvider = { getAppHostPath: getAppHostPathStub, tryExecuteDoAppHost: tryExecuteDoAppHostStub, tryExecuteRunAppHost: tryExecuteRunAppHostStub, + tryExecuteDeployAppHost: tryExecuteDeployAppHostStub, + tryExecutePublishAppHost: tryExecutePublishAppHostStub, } as unknown as AspireEditorCommandProvider; registerCliCommands(terminalProvider, editorCommandProvider, new ConfigInfoProvider(terminalProvider)); @@ -288,6 +295,50 @@ suite('registerCliCommands', () => { assert.ok(tryExecuteDoAppHostStub.calledOnceWith(false, undefined, appHostPath, target, '/resolved/aspire')); }); + test('deploy and publish warn for the same multi-root CLI they launch', async () => { + const folderA = createWorkspaceFolder('a', '/repo/a'); + const folderB = createWorkspaceFolder('b', '/repo/b'); + const appHostPath = '/repo/b/AppHost/AppHost.csproj'; + const target = workspaceFolderCliPathTarget(folderB); + workspaceFoldersStub.value([folderA, folderB]); + getAppHostPathStub.resolves(appHostPath); + getWorkspaceFolderStub.returns(folderB); + resolveCliPathStub.callsFake(async resolvedTarget => ({ + cliPath: resolvedTarget.kind === 'workspaceFolder' && + resolvedTarget.workspaceFolder.uri.toString() === folderB.uri.toString() + ? '/repo/b/.aspire/bin/aspire' + : '/global/aspire', + available: true, + source: 'configured', + })); + const resolutions: Array<{ target: CliPathResolutionTarget; cliPath: string }> = []; + const resolutionSubscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); + + try { + await callbacks.get('aspire-vscode.deploy')!(); + await callbacks.get('aspire-vscode.publish')!(); + } + finally { + resolutionSubscription.dispose(); + } + + assert.deepStrictEqual(resolutions, [ + { target, cliPath: '/repo/b/.aspire/bin/aspire' }, + { target, cliPath: '/repo/b/.aspire/bin/aspire' }, + ]); + assert.ok(tryExecuteDeployAppHostStub.calledOnceWith( + false, + appHostPath, + target, + '/repo/b/.aspire/bin/aspire')); + assert.ok(tryExecutePublishAppHostStub.calledOnceWith( + false, + appHostPath, + target, + '/repo/b/.aspire/bin/aspire')); + assert.strictEqual(resolveCliPathStub.neverCalledWith(windowCliPathTarget), true); + }); + test('do rejects a missing AppHost before probing the CLI', async () => { const hasCapabilityStub = sandbox.stub(ConfigInfoProvider.prototype, 'hasCapability').resolves(true); const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index e7d91273675..cf3f8e9f17c 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -27,6 +27,8 @@ const defaultSurface: OutdatedCliNotificationSurface = { */ export class OutdatedCliNotifier implements vscode.Disposable { private readonly _notifiedCliVersions = new Set(); + private readonly _cancellationSource = new vscode.CancellationTokenSource(); + private _disposed = false; constructor( private readonly _versionProvider: CliVersionProvider, @@ -35,9 +37,17 @@ export class OutdatedCliNotifier implements vscode.Disposable { } async notifyIfOutdated(target: CliPathResolutionTarget, cliPath?: string): Promise { - const options: CliVersionStatusOptions = { target, cliPath }; + if (this._disposed) { + return; + } + + const options: CliVersionStatusOptions = { + target, + cliPath, + cancellationToken: this._cancellationSource.token, + }; const result = await this._versionProvider.getCliVersionStatus(minimumSupportedAspireCliVersion, options); - if (result?.status !== 'unsupported') { + if (this._disposed || result?.status !== 'unsupported') { return; } @@ -52,7 +62,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { const selection = await this._surface.showWarning( strings.outdatedAspireCliWarning(result.version, minimumSupportedAspireCliVersion), strings.updateAspireCliAction); - if (selection === strings.updateAspireCliAction) { + if (!this._disposed && selection === strings.updateAspireCliAction) { await this._surface.executeCommand(updateAspireCliCommand, target, result.cliPath); } } @@ -66,6 +76,13 @@ export class OutdatedCliNotifier implements vscode.Disposable { } dispose(): void { + if (this._disposed) { + return; + } + + this._disposed = true; + this._cancellationSource.cancel(); + this._cancellationSource.dispose(); this._notifiedCliVersions.clear(); } } From 03018180f6d962ae3291a0602415c2e2f758f5be Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 25 Aug 2026 11:54:29 -0700 Subject: [PATCH 04/31] Stabilize outdated CLI notification E2E Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../cliPathRejectionNotification.e2e.test.ts | 4 ++-- extension/src/test-e2e/helpers/vscode.ts | 19 ++++++++++++++++--- .../test/notificationWaitForMessage.test.ts | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts index 7c39d7dd059..79a108a148a 100644 --- a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts +++ b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts @@ -18,7 +18,7 @@ import { } from './helpers/fixtures'; import { getWorkspaceRoot } from './helpers/paths'; import { VSBrowser } from './helpers/extester'; -import { closeAllEditors, openAspireView, waitForNotificationMessage, waitForWorkbenchText } from './helpers/vscode'; +import { openAspireView, waitForNotificationMessage, waitForWorkbenchText } from './helpers/vscode'; // Mirrors configuredCliPathRejected in src/loc/strings.ts. const rejectionNotificationText = 'The configured Aspire CLI path could not be used'; @@ -33,7 +33,7 @@ suite('Configured CLI path rejection E2E', function () { teardown(async () => { await runE2eTeardown([ - () => closeAllEditors(), + () => executeE2eControlCommand({ name: 'closeAllEditors' }), () => setTerminalCommandExecutionSuppressedForE2E(false), () => restoreE2eCliPathForE2E(), () => restoreWorkspaceCliPath(), diff --git a/extension/src/test-e2e/helpers/vscode.ts b/extension/src/test-e2e/helpers/vscode.ts index 0acb23d27e1..ef6cfa27d81 100644 --- a/extension/src/test-e2e/helpers/vscode.ts +++ b/extension/src/test-e2e/helpers/vscode.ts @@ -357,9 +357,22 @@ export async function waitForNotificationMessage(expectedText: string, timeoutMs try { const notifications = await new Workbench().getNotifications(); for (const notification of notifications) { - const message = await notification.getMessage(); - if (message.includes(expectedText)) { - return notification; + try { + const message = await notification.getMessage(); + if (message.includes(expectedText)) { + return notification; + } + } + catch (error) { + // VS Code keeps dismissed notifications in the DOM briefly. ExTester waits for + // visibility before reading them, so skip a hidden stale entry and continue to + // the current visible notification in the same poll. + if (error instanceof webDriverError.StaleElementReferenceError || + error instanceof webDriverError.TimeoutError) { + continue; + } + + throw error; } } diff --git a/extension/src/test/notificationWaitForMessage.test.ts b/extension/src/test/notificationWaitForMessage.test.ts index 45c3fb961d0..977dbc00c7d 100644 --- a/extension/src/test/notificationWaitForMessage.test.ts +++ b/extension/src/test/notificationWaitForMessage.test.ts @@ -83,6 +83,23 @@ suite('waitForNotificationMessage', () => { assert.deepStrictEqual(waitState.pollResults, [false, freshNotification]); }); + test('skips a hidden dismissed notification before reading the visible notification', async () => { + const hiddenNotification = createNotification(() => { + throw new webDriverError.TimeoutError('Waiting until element is visible'); + }); + const freshNotification = createNotification('Aspire Dashboard ready'); + const { stub, vscode } = loadNotificationWaitModules(); + + stub.setNotificationPolls([[hiddenNotification, freshNotification]]); + + const notification = await vscode.waitForNotificationMessage('Dashboard ready', 5000); + const waitState = stub.getNotificationWaitState(); + + assert.strictEqual(notification, freshNotification); + assert.strictEqual(waitState.notificationPollCount, 1); + assert.deepStrictEqual(waitState.pollResults, [freshNotification]); + }); + test('propagates non-stale WebDriver failures', async () => { const sessionError = new webDriverError.NoSuchSessionError('session closed'); const { stub, vscode } = loadNotificationWaitModules(); From 067268e39267b044159807ca68cc78c0976ed6e2 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 25 Aug 2026 14:18:31 -0700 Subject: [PATCH 05/31] Warn from exact Aspire CLI operations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- extension/loc/xlf/aspire-vscode.xlf | 2 +- extension/package.nls.json | 2 +- extension/src/data/AppHostDataRepository.ts | 35 +++-- extension/src/data/appHostPsPoller.ts | 3 + extension/src/extension.ts | 8 +- extension/src/loc/strings.ts | 2 +- .../cliPathRejectionNotification.e2e.test.ts | 5 +- .../src/test/appHostDataRepository.test.ts | 131 ++++++++++++++++-- extension/src/test/appHostDiscovery.test.ts | 43 ++++++ .../src/test/outdatedCliNotifier.test.ts | 101 ++++++++++++-- .../src/test/registerCliCommands.test.ts | 2 +- extension/src/test/workspace.test.ts | 3 +- extension/src/utils/appHostDiscovery.ts | 51 ++++++- extension/src/utils/cliOperationResolution.ts | 19 +++ extension/src/utils/outdatedCliNotifier.ts | 82 ++++++++--- extension/src/utils/workspace.ts | 16 +-- 16 files changed, 428 insertions(+), 77 deletions(-) create mode 100644 extension/src/utils/cliOperationResolution.ts diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 3c453959323..8c0cef5da2a 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -59,7 +59,7 @@ Aspire CLI is not available on PATH. Please install it and restart VS Code. - Aspire CLI {0} is older than {1}. Update the CLI and the AppHost's Aspire packages to {1} or later to avoid a known startup failure in VS Code. + Aspire CLI {0} at {1} is older than {2}. Update the CLI and the AppHost's Aspire packages to {2} or later to avoid a known startup failure in VS Code. Aspire Extension diff --git a/extension/package.nls.json b/extension/package.nls.json index d1768beea59..bd762dd6af4 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -86,7 +86,7 @@ "aspire-vscode.strings.aspireCliPathEnvironmentDescription": "Forwards aspire.aspireCliExecutablePath as AspireCliPath so MSBuild bundle resolution and integrated terminals use the configured Aspire CLI.", "aspire-vscode.strings.configuredCliPathRejected": "The configured Aspire CLI path could not be used, so a different Aspire CLI is running instead: {0}. Check that aspire.aspireCliExecutablePath points to the aspire executable itself and that it runs.", "aspire-vscode.strings.configuredCliPathRejectedOpenSetting": "Open Setting", - "aspire-vscode.strings.outdatedAspireCliWarning": "Aspire CLI {0} is older than {1}. Update the CLI and the AppHost's Aspire packages to {1} or later to avoid a known startup failure in VS Code.", + "aspire-vscode.strings.outdatedAspireCliWarning": "Aspire CLI {0} at {1} is older than {2}. Update the CLI and the AppHost's Aspire packages to {2} or later to avoid a known startup failure in VS Code.", "aspire-vscode.strings.updateAspireCliAction": "Update Aspire CLI", "aspire-vscode.strings.installCliPlaceholder": "Select how to install the Aspire CLI", "aspire-vscode.strings.installCliViewAllOptions": "View all installation options", diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index 4748ea83707..69d5e8b2337 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -18,6 +18,7 @@ import { isMatchingAppHostInstance, isMatchingAppHostPath, isPathInWorkspace } f import { AppHostPsPoller } from './appHostPsPoller'; import { filterResourceCommandStatusOutput } from './resourceCommandStatusOutput'; import { getCliPathTargetForUri } from '../utils/cliPathVariables'; +import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; export * from './appHostCliContracts'; export { shortenPath, shortenPaths }; @@ -84,9 +85,6 @@ export class AppHostDataRepository { private readonly _onDidChangeData = new vscode.EventEmitter(); readonly onDidChangeData = this._onDidChangeData.event; - private readonly _onDidBecomeDataActive = new vscode.EventEmitter(); - readonly onDidBecomeDataActive = this._onDidBecomeDataActive.event; - // ── Mode / panel state ── private _viewMode: ViewMode = 'workspace'; private _panelVisible = false; @@ -187,7 +185,19 @@ export class AppHostDataRepository { }); this._fetchWorkspaceAppHost(); this._configChangeDisposable = vscode.workspace.onDidChangeConfiguration(e => { - if ((e.affectsConfiguration('aspire.appHostsPollingInterval') || e.affectsConfiguration('aspire.globalAppHostsPollingInterval')) && this._dataActive) { + const cliPathChanged = e.affectsConfiguration('aspire.aspireCliExecutablePath'); + if (cliPathChanged) { + this._markWorkspaceAppHostDiscoveryPending({ preserveCandidates: true }); + this._fetchWorkspaceAppHost({ forceRefresh: true }); + if (this._dataActive) { + this._psPoller.startPsPolling(); + this._stopAllDescribes(); + this._reconcileDescribes(); + } + } + if (!cliPathChanged && + (e.affectsConfiguration('aspire.appHostsPollingInterval') || e.affectsConfiguration('aspire.globalAppHostsPollingInterval')) && + this._dataActive) { this._psPoller.startPsPolling(); } }); @@ -290,7 +300,7 @@ export class AppHostDataRepository { this._hasEverBeenDataActive = true; } if (becameDataActive) { - this._onDidBecomeDataActive.fire(); + this._reportWorkspaceDiscoveryCliResolutions(); } this._syncPolling(resumedFromInactive); } @@ -306,7 +316,7 @@ export class AppHostDataRepository { const resumedFromInactive = becameDataActive && this._hasEverBeenDataActive; this._hasEverBeenDataActive = true; if (becameDataActive) { - this._onDidBecomeDataActive.fire(); + this._reportWorkspaceDiscoveryCliResolutions(); } this._syncPolling(resumedFromInactive); @@ -348,7 +358,7 @@ export class AppHostDataRepository { this._hasEverBeenDataActive = true; } if (becameDataActive) { - this._onDidBecomeDataActive.fire(); + this._reportWorkspaceDiscoveryCliResolutions(); } // Re-scope the displayed AppHosts against the new open-tab set right away. this._handlePsSnapshot(this._appHosts, { force: true }); @@ -580,12 +590,17 @@ export class AppHostDataRepository { this._psPollerDisposable.dispose(); this._psPoller.dispose(); this._onDidChangeData.dispose(); - this._onDidBecomeDataActive.dispose(); if (this._ownsAppHostDiscoveryService) { this._appHostDiscoveryService.dispose(); } } + private _reportWorkspaceDiscoveryCliResolutions(): void { + for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { + this._appHostDiscoveryService.reportResolvedCliForWorkspace?.(workspaceFolder); + } + } + // ── PS polling lifecycle ── /** @@ -716,7 +731,8 @@ export class AppHostDataRepository { folderCandidates.workspaceFolder, options?.forceRefresh, cancellationSource.token, - candidate => onIncrementalCandidate(folderCandidates, candidate)); + candidate => onIncrementalCandidate(folderCandidates, candidate), + this._dataActive); } catch (error) { folderCandidates.candidates = []; errors[workspaceFolderIndex] = { @@ -1026,6 +1042,7 @@ export class AppHostDataRepository { if (this._disposed || this._describeStreams.get(appHostPath) !== stream || startVersion !== stream.version) { return; } + reportCliResolvedForOperation(target, cliPath); // The capability is a property of the CLI this AppHost resolves to, not of the window: a // multi-root workspace can point each folder at a different aspire.cliPath, so a single diff --git a/extension/src/data/appHostPsPoller.ts b/extension/src/data/appHostPsPoller.ts index 69e571048b3..4b6832549d3 100644 --- a/extension/src/data/appHostPsPoller.ts +++ b/extension/src/data/appHostPsPoller.ts @@ -6,6 +6,7 @@ import { extensionLogOutputChannel } from '../utils/logging'; import { errorFetchingAppHosts } from '../loc/strings'; import { AppHostCliRunner, LimitedOutputBuffer, oneShotOutputBufferLimit } from './appHostCliRunner'; import { windowCliPathTarget } from '../utils/cliPathVariables'; +import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; export interface PsOutput { readonly stdout: string; @@ -160,6 +161,7 @@ export class AppHostPsPoller implements vscode.Disposable { if (!this._isCurrentPsFetch(fetchVersion)) { return; } + reportCliResolvedForOperation(windowCliPathTarget, cliPath); let psProcess: ChildProcessWithoutNullStreams | undefined; let psProcessCompletedSynchronously = false; @@ -345,6 +347,7 @@ export class AppHostPsPoller implements vscode.Disposable { if (!isCurrentPsCommand()) { return; } + reportCliResolvedForOperation(windowCliPathTarget, cliPath); const invocationArgs = this._cliRunner.normalizeNoLogoArgs(cliPath, args); let stdout = ''; diff --git a/extension/src/extension.ts b/extension/src/extension.ts index bb75a455602..cc926d98398 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -12,7 +12,7 @@ import AspireDcpServer from './dcp/AspireDcpServer'; import { TestRunSessionManager } from './dcp/TestRunSessionManager'; import { AspireTerminalProvider } from './utils/AspireTerminalProvider'; import { MessageConnection } from 'vscode-jsonrpc'; -import { checkForExistingAppHostPathInWorkspace, onDidResolveCliForOperation } from './utils/workspace'; +import { checkForExistingAppHostPathInWorkspace } from './utils/workspace'; import { AspireEditorCommandProvider } from './editor/AspireEditorCommandProvider'; import { AspirePackageRestoreProvider } from './utils/AspirePackageRestoreProvider'; import { AspireAppHostTreeProvider } from './views/AspireAppHostTreeProvider'; @@ -39,6 +39,7 @@ import { registerTreeViewCommands } from './activation/registerTreeViewCommands' import { registerCodeLensCommands } from './activation/registerCodeLensCommands'; import { initializeHotReloadAdvisory } from './debugger/hotReload'; import { OutdatedCliNotifier } from './utils/outdatedCliNotifier'; +import { onDidResolveCliForOperation } from './utils/cliOperationResolution'; let aspireExtensionContext = new AspireExtensionContext(); @@ -128,11 +129,6 @@ export async function activate(context: vscode.ExtensionContext) { // Aspire panel - running app hosts tree view const dataRepository = new AppHostDataRepository(terminalProvider, appHostDiscoveryService, configInfoProvider); - context.subscriptions.push(dataRepository.onDidBecomeDataActive(() => { - void outdatedCliNotifier.notifyForActiveCliTargets().catch(error => { - extensionLogOutputChannel.warn(`Unable to check Aspire CLI version: ${String(error)}`); - }); - })); appHostLaunchService.setEditorSessionProvider(() => aspireExtensionContext.aspireDebugSessions); appHostLaunchService.setRunningAppHostProvider(async token => { const appHosts = await dataRepository.fetchRunningAppHostsOnce(token); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index ff4f745f88f..d186b66717f 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -185,7 +185,7 @@ export const failedToGetConfigInfo = (exitCode: number) => vscode.l10n.t('Failed export const failedToParseConfigInfo = (error: any) => vscode.l10n.t('Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); export const errorGettingConfigInfo = (error: any) => vscode.l10n.t('Error getting Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); export const configInfoTimedOut = (seconds: number) => vscode.l10n.t('Aspire config info timed out after {0} seconds.', seconds); -export const outdatedAspireCliWarning = (version: string, minimumVersion: string) => vscode.l10n.t("Aspire CLI {0} is older than {1}. Update the CLI and the AppHost's Aspire packages to {1} or later to avoid a known startup failure in VS Code.", version, minimumVersion); +export const outdatedAspireCliWarning = (version: string, cliPath: string, minimumVersion: string) => vscode.l10n.t("Aspire CLI {0} at {1} is older than {2}. Update the CLI and the AppHost's Aspire packages to {2} or later to avoid a known startup failure in VS Code.", version, cliPath, minimumVersion); export const updateAspireCliAction = vscode.l10n.t({ message: 'Update Aspire CLI', comment: 'Button label that updates the installed Aspire CLI.' }); export const invalidLaunchConfiguration = (projectPath: string) => vscode.l10n.t('Invalid launch configuration for {0}.', projectPath); export const browserDisplayName = (url: string) => vscode.l10n.t('Browser: {0}', url); diff --git a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts index 79a108a148a..5f502bb0b08 100644 --- a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts +++ b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts @@ -23,7 +23,8 @@ import { openAspireView, waitForNotificationMessage, waitForWorkbenchText } from // Mirrors configuredCliPathRejected in src/loc/strings.ts. const rejectionNotificationText = 'The configured Aspire CLI path could not be used'; const openSettingActionText = 'Open Setting'; -const outdatedCliWarningText = "Aspire CLI 13.4.9 is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later"; +const outdatedCliWarningText = 'Aspire CLI 13.4.9 at '; +const outdatedCliGuidanceText = "is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later"; const updateCliActionText = 'Update Aspire CLI'; suite('Configured CLI path rejection E2E', function () { @@ -124,6 +125,8 @@ suite('Configured CLI path rejection E2E', function () { const message = await notification.getMessage(); await VSBrowser.instance.takeScreenshot('outdated-aspire-cli-warning').catch(() => undefined); assert.ok(message.includes(outdatedCliWarningText), `Unexpected warning message: ${message}`); + assert.ok(message.includes(wrapper.cliPath), `Warning did not identify the exact CLI '${wrapper.cliPath}': ${message}`); + assert.ok(message.includes(outdatedCliGuidanceText), `Warning did not include the expected update guidance: ${message}`); const beforeTerminalCommand = getTerminalCommandCount(); await notification.takeAction(updateCliActionText); diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 2151a53cd85..a35b9668f9c 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -15,6 +15,7 @@ import * as configInfoProvider from '../utils/configInfoProvider'; import { describeIncludeDisabledCommandsCapability, lsJsonStreamCapability } from '../types/configInfo'; import { errorFetchingAppHosts } from '../loc/strings'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; import { removeDirectorySafely } from './testHelpers'; class TestChildProcess extends EventEmitter { @@ -2745,6 +2746,30 @@ suite('AppHostDataRepository', () => { } }); + test('describe reports the exact CLI path for each active workspace folder', async () => { + const resolutions: Array<{ folder: string; cliPath: string }> = []; + const resolutionSubscription = onDidResolveCliForOperation(resolution => { + if (resolution.target.kind === 'workspaceFolder') { + resolutions.push({ + folder: resolution.target.workspaceFolder.name, + cliPath: resolution.cliPath, + }); + } + }); + const context = await startTwoFolderDescribeStreams(); + + try { + assert.deepStrictEqual(resolutions.sort((left, right) => left.folder.localeCompare(right.folder)), [ + { folder: 'peer', cliPath: '/cli/peer/aspire' }, + { folder: 'selected', cliPath: '/cli/selected/aspire' }, + ]); + } + finally { + resolutionSubscription.dispose(); + context.dispose(); + } + }); + test('working peer CLI does not clear the selected AppHost compatibility error', async () => { const context = await startTwoFolderDescribeStreams(); @@ -6213,29 +6238,100 @@ suite('AppHostDataRepository global polling', () => { repository.dispose(); }); - test('notifies only when Aspire data sources become active', () => { - let activationCount = 0; - const repository = new AppHostDataRepository(terminalProvider); - const subscription = repository.onDidBecomeDataActive(() => activationCount++); + test('reports cached discovery CLI paths only when data sources become active', async () => { + const workspaceFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }; + const workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([workspaceFolder]); + const candidateChange = new vscode.EventEmitter(); + const reportResolvedCliForWorkspace = sinon.stub(); + const discoveryService = { + discover: sinon.stub().resolves([]), + reportResolvedCliForWorkspace, + onDidChangeCandidates: candidateChange.event, + dispose: () => { }, + } as unknown as AppHostDiscoveryService; + const repository = new AppHostDataRepository(terminalProvider, discoveryService); - repository.activate(); - assert.strictEqual(activationCount, 0); + try { + repository.activate(); + await waitForMicrotasks(); + assert.strictEqual(reportResolvedCliForWorkspace.callCount, 0); - repository.setPanelVisible(true); - repository.setAppHostFilesOpen(['/workspace/AppHost.csproj']); - assert.strictEqual(activationCount, 1); + repository.setPanelVisible(true); + assert.ok(reportResolvedCliForWorkspace.calledOnceWithExactly(workspaceFolder)); - repository.setPanelVisible(false); - repository.setAppHostFilesOpen([]); - repository.setPanelVisible(true); - assert.strictEqual(activationCount, 2); + repository.setAppHostFilesOpen(['/workspace/AppHost.csproj']); + assert.strictEqual(reportResolvedCliForWorkspace.callCount, 1); - subscription.dispose(); - repository.dispose(); + repository.setPanelVisible(false); + repository.setAppHostFilesOpen([]); + repository.setPanelVisible(true); + assert.strictEqual(reportResolvedCliForWorkspace.callCount, 2); + } + finally { + repository.dispose(); + candidateChange.dispose(); + workspaceFoldersStub.restore(); + } + }); + + test('an active scoped CLI path change forces discovery to report the new exact path', async () => { + const workspaceFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }; + const workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([workspaceFolder]); + const configurationChange = new vscode.EventEmitter(); + const onDidChangeConfigurationStub = sinon.stub(vscode.workspace, 'onDidChangeConfiguration') + .callsFake(listener => configurationChange.event(listener)); + const candidateChange = new vscode.EventEmitter(); + const discover = sinon.stub().resolves([]); + const discoveryService = { + discover, + reportResolvedCliForWorkspace: sinon.stub(), + onDidChangeCandidates: candidateChange.event, + dispose: () => { }, + } as unknown as AppHostDiscoveryService; + const repository = new AppHostDataRepository(terminalProvider, discoveryService); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForCondition(() => discover.callCount > 0, 'initial workspace discovery did not complete'); + discover.resetHistory(); + + configurationChange.fire({ + affectsConfiguration: section => section === 'aspire.aspireCliExecutablePath', + }); + await waitForCondition(() => discover.callCount > 0, 'CLI path change did not refresh workspace discovery'); + + assert.strictEqual(discover.lastCall.args[1], true); + assert.strictEqual(discover.lastCall.args[4], true); + } + finally { + repository.dispose(); + candidateChange.dispose(); + configurationChange.dispose(); + onDidChangeConfigurationStub.restore(); + workspaceFoldersStub.restore(); + } }); test('global ps follow and one-shot refresh resolve with the window target', async () => { const repository = new AppHostDataRepository(terminalProvider); + const resolutions: Array<{ target: typeof windowCliPathTarget; cliPath: string }> = []; + const resolutionSubscription = onDidResolveCliForOperation(resolution => { + if (resolution.target.kind === 'window') { + resolutions.push({ + target: resolution.target, + cliPath: resolution.cliPath, + }); + } + }); try { repository.activate(); @@ -6248,7 +6344,12 @@ suite('AppHostDataRepository global polling', () => { assert.deepStrictEqual(getCliPathStub.firstCall.args, [windowCliPathTarget]); assert.deepStrictEqual(getCliPathStub.secondCall.args, [windowCliPathTarget]); + assert.deepStrictEqual(resolutions, [ + { target: windowCliPathTarget, cliPath: 'aspire' }, + { target: windowCliPathTarget, cliPath: 'aspire' }, + ]); } finally { + resolutionSubscription.dispose(); repository.dispose(); } }); diff --git a/extension/src/test/appHostDiscovery.test.ts b/extension/src/test/appHostDiscovery.test.ts index 1edfa82690d..fc544086c91 100644 --- a/extension/src/test/appHostDiscovery.test.ts +++ b/extension/src/test/appHostDiscovery.test.ts @@ -15,6 +15,7 @@ import { lsJsonStreamCapability } from '../types/configInfo'; import { __resetCommonPropertiesForTests, __setReporterForTests } from '../utils/telemetry'; import { appHostDiscoveryFindFilesMaxResults } from '../utils/workspaceFileSearch'; import { workspaceFolderCliPathTarget, getCliPathTargetKey } from '../utils/cliPathVariables'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; import { removeDirectorySafely } from './testHelpers'; interface RecordedEvent { @@ -208,6 +209,48 @@ suite('AppHost discovery', () => { sandbox.restore(); }); + test('reports only active discovery CLI resolutions and observes a scoped path change', async () => { + stubFileSystemWatchers(sandbox); + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + let cliPath = buildPath('cli', 'first', 'aspire'); + const terminalProvider = { + getAspireCliExecutablePath: async () => cliPath, + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + emitLsOutput(options, []); + return { kill: () => { } } as any; + }); + const resolutions: Array<{ targetKey: string; cliPath: string }> = []; + const resolutionSubscription = onDidResolveCliForOperation(resolution => resolutions.push({ + targetKey: getCliPathTargetKey(resolution.target), + cliPath: resolution.cliPath, + })); + const service = new AppHostDiscoveryService(terminalProvider); + + try { + await service.discover(workspaceFolder); + assert.deepStrictEqual(resolutions, []); + + service.reportResolvedCliForWorkspace(workspaceFolder); + assert.deepStrictEqual(resolutions, [{ + targetKey: getCliPathTargetKey(workspaceFolderCliPathTarget(workspaceFolder)), + cliPath, + }]); + + cliPath = buildPath('cli', 'second', 'aspire'); + await service.discover(workspaceFolder, true, undefined, undefined, true); + assert.deepStrictEqual(resolutions.at(-1), { + targetKey: getCliPathTargetKey(workspaceFolderCliPathTarget(workspaceFolder)), + cliPath, + }); + } + finally { + resolutionSubscription.dispose(); + service.dispose(); + } + }); + test('does not force refresh discovery after cached negative editor lookup', async () => { stubFileSystemWatchers(sandbox); const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index 2f03532151f..3ea7ffebb70 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -56,9 +56,9 @@ suite('outdatedCliNotifier', () => { status: 'supported', }; - await notifier.notifyIfOutdated(windowCliPathTarget); + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); versionProvider.result = null; - await notifier.notifyIfOutdated(windowCliPathTarget); + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); assert.deepStrictEqual(surface.warnings, []); assert.deepStrictEqual( @@ -75,14 +75,14 @@ suite('outdatedCliNotifier', () => { status: 'unsupported', }; - await notifier.notifyIfOutdated(windowCliPathTarget); - await notifier.notifyIfOutdated(windowCliPathTarget); - await notifier.notifyIfOutdated(windowCliPathTarget); + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); assert.strictEqual(surface.warnings.length, 1); assert.strictEqual( surface.warnings[0].message, - "Aspire CLI 13.4.9 is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later to avoid a known startup failure in VS Code."); + "Aspire CLI 13.4.9 at /cli/aspire is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later to avoid a known startup failure in VS Code."); notifier.dispose(); }); @@ -103,7 +103,91 @@ suite('outdatedCliNotifier', () => { assert.deepStrictEqual( surface.warnings.map(warning => warning.message), outdatedVersions.map(version => - `Aspire CLI ${version.version} is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later to avoid a known startup failure in VS Code.`)); + `Aspire CLI ${version.version} at ${version.cliPath} is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later to avoid a known startup failure in VS Code.`)); + assert.notStrictEqual(surface.warnings[0].message, surface.warnings[1].message); + notifier.dispose(); + }); + + test('coalesces shared multi-root paths and bounds distinct version probes', async () => { + let activeProbeCount = 0; + let maximumActiveProbeCount = 0; + const probedPaths: string[] = []; + const releaseProbes: Array<() => void> = []; + const versionProvider = { + getCliVersionStatus: async ( + _minimumVersion: string, + options?: CliVersionStatusOptions, + ): Promise => { + const cliPath = options?.cliPath; + assert.ok(cliPath); + probedPaths.push(cliPath); + activeProbeCount++; + maximumActiveProbeCount = Math.max(maximumActiveProbeCount, activeProbeCount); + return await new Promise(resolve => { + releaseProbes.push(() => { + activeProbeCount--; + resolve({ + cliPath, + version: '13.5.0', + status: 'supported', + }); + }); + }); + }, + }; + const surface = new FakeSurface(); + const notifier = new OutdatedCliNotifier(versionProvider, surface); + const requests = Array.from({ length: 40 }, (_, index) => { + const target = workspaceFolderCliPathTarget({ + uri: vscode.Uri.file(`/workspace/${index}`), + name: `folder-${index}`, + index, + }); + const cliPath = index < 20 ? '/shared/aspire' : `/cli/${index}/aspire`; + return notifier.notifyIfOutdated(target, cliPath); + }); + const expectedProbeCount = 21; + let releasedProbeCount = 0; + + while (releasedProbeCount < expectedProbeCount) { + await new Promise(resolve => setImmediate(resolve)); + const batch = releaseProbes.splice(0); + assert.ok(batch.length > 0, 'Expected a bounded batch of version probes to be ready.'); + releasedProbeCount += batch.length; + batch.forEach(release => release()); + } + await Promise.all(requests); + + assert.strictEqual(probedPaths.filter(cliPath => cliPath === '/shared/aspire').length, 1); + assert.strictEqual(probedPaths.length, expectedProbeCount); + assert.strictEqual(maximumActiveProbeCount, 4); + assert.deepStrictEqual(surface.warnings, []); + notifier.dispose(); + }); + + test('reprobes the same active CLI path and observes an in-place replacement', async () => { + const results: CliVersionStatus[] = [{ + cliPath: '/cli/aspire', + version: '13.5.0', + status: 'supported', + }, { + cliPath: '/cli/aspire', + version: '13.4.9', + status: 'unsupported', + }]; + let probeIndex = 0; + const versionProvider = { + getCliVersionStatus: async (): Promise => results[probeIndex++], + }; + const surface = new FakeSurface(); + const notifier = new OutdatedCliNotifier(versionProvider, surface); + + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual(probeIndex, 2); + assert.strictEqual(surface.warnings.length, 1); + assert.ok(surface.warnings[0].message.includes('Aspire CLI 13.4.9 at /cli/aspire is older than 13.5.0')); notifier.dispose(); }); @@ -121,7 +205,7 @@ suite('outdatedCliNotifier', () => { }; surface.selection = strings.updateAspireCliAction; - await notifier.notifyIfOutdated(target); + await notifier.notifyIfOutdated(target, '/workspace/a/.aspire/bin/aspire'); assert.deepStrictEqual(surface.warnings[0].actions, [strings.updateAspireCliAction]); assert.deepStrictEqual(surface.commands, [{ @@ -137,6 +221,7 @@ suite('outdatedCliNotifier', () => { versionProvider.resultPromise = new Promise(resolve => resolveProbe = resolve); const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await new Promise(resolve => setImmediate(resolve)); const cancellationToken = versionProvider.calls[0].options?.cancellationToken; assert.ok(cancellationToken); assert.strictEqual(cancellationToken.isCancellationRequested, false); diff --git a/extension/src/test/registerCliCommands.test.ts b/extension/src/test/registerCliCommands.test.ts index e576b072cd3..7b48ed4da0e 100644 --- a/extension/src/test/registerCliCommands.test.ts +++ b/extension/src/test/registerCliCommands.test.ts @@ -13,7 +13,7 @@ import * as cliPathModule from '../utils/cliPath'; import { ConfigInfoProvider } from '../utils/configInfoProvider'; import { CliPathResolutionTarget, windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { CommandInvocationEvent, onDidInvokeCommand } from '../utils/telemetry'; -import { onDidResolveCliForOperation } from '../utils/workspace'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; import { createWorkspaceFolder, removeDirectorySafely } from './testHelpers'; suite('registerCliCommands', () => { let sandbox: sinon.SinonSandbox; diff --git a/extension/src/test/workspace.test.ts b/extension/src/test/workspace.test.ts index 21001c88f1a..7ad9de70415 100644 --- a/extension/src/test/workspace.test.ts +++ b/extension/src/test/workspace.test.ts @@ -5,7 +5,8 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { yesLabel } from '../loc/strings'; -import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles, onDidResolveCliForOperation } from '../utils/workspace'; +import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; import { AppHostDiscoveryService, getWorkspaceAppHostProjectSearchResult } from '../utils/appHostDiscovery'; import { getAppHostDiscoveryExcludeGlob } from '../utils/workspaceFileSearch'; import * as cliPathModule from '../utils/cliPath'; diff --git a/extension/src/utils/appHostDiscovery.ts b/extension/src/utils/appHostDiscovery.ts index 27ebaa7718a..661f74e0794 100644 --- a/extension/src/utils/appHostDiscovery.ts +++ b/extension/src/utils/appHostDiscovery.ts @@ -20,6 +20,7 @@ import { createLsStreamCandidateHandler, parseCandidateOutput, parseLegacyGetApp import { discoverProjectAppHostsFromWorkspaceFiles, findConfiguredAppHostPaths, formatErrorMessage } from './appHostProjectFiles'; import { findCandidateForEditorFile, findWorkspaceDefaultCandidate, getDebugTargetForCandidate, sortCandidatesByPath } from './appHostCandidateSelection'; import type { CandidateAppHostDisplayInfo, IncrementalCandidateCallback } from './appHostCandidateTypes'; +import { reportCliResolvedForOperation } from './cliOperationResolution'; export { isSamePath }; export { isSameFileSystemEntry }; @@ -47,6 +48,8 @@ interface CachedAppHostDiscovery { completed: boolean; started: boolean; stale: boolean; + reportCliResolution: boolean; + resolvedCliPath?: string; } interface CliProcessResult { @@ -74,7 +77,13 @@ export class AppHostDiscoveryService implements vscode.Disposable { this._configInfoProvider = configInfoProvider ?? new ConfigInfoProvider(_terminalProvider); } - async discover(workspaceFolder: vscode.WorkspaceFolder, forceRefresh = false, cancellationToken?: vscode.CancellationToken, onIncrementalCandidate: IncrementalCandidateCallback = () => { }): Promise { + async discover( + workspaceFolder: vscode.WorkspaceFolder, + forceRefresh = false, + cancellationToken?: vscode.CancellationToken, + onIncrementalCandidate: IncrementalCandidateCallback = () => { }, + reportCliResolution = false, + ): Promise { this._throwIfDisposed(); throwIfCancellationRequested(cancellationToken); @@ -96,6 +105,12 @@ export class AppHostDiscoveryService implements vscode.Disposable { if (!cachedDiscovery) { cachedDiscovery = this._createCachedDiscovery(workspaceFolder, key, forceRefresh); } + if (reportCliResolution) { + cachedDiscovery.reportCliResolution = true; + if (cachedDiscovery.resolvedCliPath) { + reportCliResolvedForOperation(workspaceFolderCliPathTarget(workspaceFolder), cachedDiscovery.resolvedCliPath); + } + } const candidateProgressCallback = (candidate: CandidateAppHostDisplayInfo): void => { if (!cancellationToken?.isCancellationRequested) { @@ -119,6 +134,18 @@ export class AppHostDiscoveryService implements vscode.Disposable { } } + reportResolvedCliForWorkspace(workspaceFolder: vscode.WorkspaceFolder): void { + const cachedDiscovery = this._cache.get(path.resolve(workspaceFolder.uri.fsPath)); + if (!cachedDiscovery) { + return; + } + + cachedDiscovery.reportCliResolution = true; + if (cachedDiscovery.resolvedCliPath) { + reportCliResolvedForOperation(workspaceFolderCliPathTarget(workspaceFolder), cachedDiscovery.resolvedCliPath); + } + } + private _createCachedDiscovery( workspaceFolder: vscode.WorkspaceFolder, key: string, @@ -135,6 +162,7 @@ export class AppHostDiscoveryService implements vscode.Disposable { completed: false, started: false, stale: false, + reportCliResolution: false, }; const reportCandidateProgress = (candidate: CandidateAppHostDisplayInfo) => { if (isExcludedDiscoveryCandidate(workspaceFolder, vscode.Uri.file(candidate.path))) { @@ -149,7 +177,17 @@ export class AppHostDiscoveryService implements vscode.Disposable { const startDiscovery = () => { cachedDiscovery.started = true; const startTime = Date.now(); - return this._discoverCore(workspaceFolder, reportCandidateProgress, cancellationSource.token, forceRefresh) + return this._discoverCore( + workspaceFolder, + reportCandidateProgress, + cancellationSource.token, + forceRefresh, + cliPath => { + cachedDiscovery.resolvedCliPath = cliPath; + if (cachedDiscovery.reportCliResolution && this._cache.get(key) === cachedDiscovery) { + reportCliResolvedForOperation(workspaceFolderCliPathTarget(workspaceFolder), cliPath); + } + }) .then(async discovery => { let candidates = discovery.candidates; try { @@ -275,10 +313,17 @@ export class AppHostDiscoveryService implements vscode.Disposable { this._onDidChangeCandidates.dispose(); } - private async _discoverCore(workspaceFolder: vscode.WorkspaceFolder, reportCandidateProgress: IncrementalCandidateCallback, cancellationToken: vscode.CancellationToken, forceRefresh: boolean): Promise { + private async _discoverCore( + workspaceFolder: vscode.WorkspaceFolder, + reportCandidateProgress: IncrementalCandidateCallback, + cancellationToken: vscode.CancellationToken, + forceRefresh: boolean, + reportResolvedCliPath: (cliPath: string) => void, + ): Promise { let cliPath: string | undefined; try { cliPath = await this._getAspireCliExecutablePath(workspaceFolder, cancellationToken); + reportResolvedCliPath(cliPath); const lsJsonStreamSupported = await this._resolveLsStreamCapability(cliPath, workspaceFolder, forceRefresh); let appHosts: CandidateAppHostDisplayInfo[]; if (lsJsonStreamSupported) { diff --git a/extension/src/utils/cliOperationResolution.ts b/extension/src/utils/cliOperationResolution.ts new file mode 100644 index 00000000000..8cc27899d3a --- /dev/null +++ b/extension/src/utils/cliOperationResolution.ts @@ -0,0 +1,19 @@ +import * as vscode from 'vscode'; +import { CliPathResolutionTarget } from './cliPathVariables'; + +export interface CliOperationResolution { + target: CliPathResolutionTarget; + cliPath: string; +} + +const cliOperationResolutionEmitter = new vscode.EventEmitter(); + +/** + * Fires after an Aspire operation has selected the exact CLI executable it will invoke. + * Resolution performed only for activation-time environment setup is intentionally excluded. + */ +export const onDidResolveCliForOperation = cliOperationResolutionEmitter.event; + +export function reportCliResolvedForOperation(target: CliPathResolutionTarget, cliPath: string): void { + cliOperationResolutionEmitter.fire({ target, cliPath }); +} diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index cf3f8e9f17c..0d999ef14ff 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -1,8 +1,8 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as strings from '../loc/strings'; -import { CliVersionStatusOptions, ConfigInfoProvider } from './configInfoProvider'; -import { CliPathResolutionTarget, windowCliPathTarget, workspaceFolderCliPathTarget } from './cliPathVariables'; +import { CliVersionStatus, CliVersionStatusOptions, ConfigInfoProvider } from './configInfoProvider'; +import { CliPathResolutionTarget } from './cliPathVariables'; import { getComparisonKey } from './paths/comparison'; export const minimumSupportedAspireCliVersion = '13.5.0'; @@ -26,8 +26,13 @@ const defaultSurface: OutdatedCliNotificationSurface = { * remains silent, while replacing a CLI in place with a different version is observed. */ export class OutdatedCliNotifier implements vscode.Disposable { + private static readonly _maxConcurrentProbes = 4; + private readonly _notifiedCliVersions = new Set(); private readonly _cancellationSource = new vscode.CancellationTokenSource(); + private readonly _inFlightByCliPath = new Map>(); + private readonly _probeWaiters: Array<() => void> = []; + private _activeProbeCount = 0; private _disposed = false; constructor( @@ -36,17 +41,44 @@ export class OutdatedCliNotifier implements vscode.Disposable { ) { } - async notifyIfOutdated(target: CliPathResolutionTarget, cliPath?: string): Promise { + async notifyIfOutdated(target: CliPathResolutionTarget, cliPath: string): Promise { if (this._disposed) { return; } - const options: CliVersionStatusOptions = { - target, - cliPath, - cancellationToken: this._cancellationSource.token, - }; - const result = await this._versionProvider.getCliVersionStatus(minimumSupportedAspireCliVersion, options); + const cliPathKey = getComparisonKey(path.normalize(cliPath)); + const existingProbe = this._inFlightByCliPath.get(cliPathKey); + if (existingProbe) { + await existingProbe; + return; + } + + const probe = this._runProbe(target, cliPath).finally(() => { + if (this._inFlightByCliPath.get(cliPathKey) === probe) { + this._inFlightByCliPath.delete(cliPathKey); + } + }); + this._inFlightByCliPath.set(cliPathKey, probe); + await probe; + } + + private async _runProbe(target: CliPathResolutionTarget, cliPath: string): Promise { + if (!await this._acquireProbeSlot()) { + return; + } + + let result: CliVersionStatus | null; + try { + const options: CliVersionStatusOptions = { + target, + cliPath, + cancellationToken: this._cancellationSource.token, + }; + result = await this._versionProvider.getCliVersionStatus(minimumSupportedAspireCliVersion, options); + } + finally { + this._releaseProbeSlot(); + } if (this._disposed || result?.status !== 'unsupported') { return; } @@ -60,19 +92,34 @@ export class OutdatedCliNotifier implements vscode.Disposable { // duplicate notifications while the first warning is still awaiting user input. this._notifiedCliVersions.add(notificationKey); const selection = await this._surface.showWarning( - strings.outdatedAspireCliWarning(result.version, minimumSupportedAspireCliVersion), + strings.outdatedAspireCliWarning(result.version, result.cliPath, minimumSupportedAspireCliVersion), strings.updateAspireCliAction); if (!this._disposed && selection === strings.updateAspireCliAction) { await this._surface.executeCommand(updateAspireCliCommand, target, result.cliPath); } } - async notifyForActiveCliTargets(): Promise { - const targets = [ - windowCliPathTarget, - ...(vscode.workspace.workspaceFolders ?? []).map(workspaceFolderCliPathTarget), - ]; - await Promise.all(targets.map(target => this.notifyIfOutdated(target))); + private async _acquireProbeSlot(): Promise { + if (this._disposed) { + return false; + } + if (this._activeProbeCount < OutdatedCliNotifier._maxConcurrentProbes) { + this._activeProbeCount++; + return true; + } + + await new Promise(resolve => this._probeWaiters.push(resolve)); + if (this._disposed) { + return false; + } + + this._activeProbeCount++; + return true; + } + + private _releaseProbeSlot(): void { + this._activeProbeCount--; + this._probeWaiters.shift()?.(); } dispose(): void { @@ -83,6 +130,9 @@ export class OutdatedCliNotifier implements vscode.Disposable { this._disposed = true; this._cancellationSource.cancel(); this._cancellationSource.dispose(); + while (this._probeWaiters.length > 0) { + this._probeWaiters.shift()?.(); + } this._notifiedCliVersions.clear(); } } diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index 31fb0e87197..0bd39691bf6 100644 --- a/extension/src/utils/workspace.ts +++ b/extension/src/utils/workspace.ts @@ -8,6 +8,7 @@ import { CliPathResolutionTarget } from './cliPathVariables'; import { AppHostDiscoveryService, AppHostProjectSearchResult, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult } from './appHostDiscovery'; import { sendTelemetryEvent } from './telemetry'; import { getCommonExcludeGlob } from './workspaceFileSearch'; +import { reportCliResolvedForOperation } from './cliOperationResolution'; export { getCommonExcludeGlob } from './workspaceFileSearch'; @@ -245,19 +246,6 @@ async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearch extensionLogOutputChannel.info(`Successfully set AppHost path to: ${appHostToUse} in ${settingsFileLocation.fsPath}`); } -export interface CliOperationResolution { - target: CliPathResolutionTarget; - cliPath: string; -} - -const cliOperationResolutionEmitter = new vscode.EventEmitter(); - -/** - * Fires only after a command or debug operation has resolved and validated the CLI it will use. - * Activation-time environment synchronization and eager workspace discovery do not raise this event. - */ -export const onDidResolveCliForOperation = cliOperationResolutionEmitter.event; - /** * Checks if the Aspire CLI is available. If not found on PATH, it checks the default * installation directory and updates the VS Code setting accordingly. @@ -295,7 +283,7 @@ export async function checkCliAvailableOrRedirect( }); if (result.available) { - cliOperationResolutionEmitter.fire({ target, cliPath: result.cliPath }); + reportCliResolvedForOperation(target, result.cliPath); // Show informational message if CLI was found at default path (not on PATH) if (result.source === 'default-install') { From 30954a2c37903e6a72d264704c771bf015181dcd Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 25 Aug 2026 14:31:19 -0700 Subject: [PATCH 06/31] Transfer CLI probe slots directly Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/test/outdatedCliNotifier.test.ts | 32 +++++++++++++++++++ extension/src/utils/outdatedCliNotifier.ts | 11 +++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index 3ea7ffebb70..b91a3e0e92c 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -165,6 +165,38 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); + test('transfers a released probe slot without exposing it to a new arrival', async () => { + const notifier = new OutdatedCliNotifier(new FakeVersionProvider(), new FakeSurface()); + const slots = notifier as unknown as { + _activeProbeCount: number; + _acquireProbeSlot(): Promise; + _releaseProbeSlot(): void; + }; + + assert.deepStrictEqual( + await Promise.all(Array.from({ length: 4 }, () => slots._acquireProbeSlot())), + [true, true, true, true]); + const firstWaiter = slots._acquireProbeSlot(); + + slots._releaseProbeSlot(); + const newArrival = slots._acquireProbeSlot(); + let newArrivalResolved = false; + void newArrival.then(() => newArrivalResolved = true); + assert.strictEqual(await firstWaiter, true); + await Promise.resolve(); + + assert.strictEqual(slots._activeProbeCount, 4); + assert.strictEqual(newArrivalResolved, false); + + slots._releaseProbeSlot(); + assert.strictEqual(await newArrival, true); + for (let i = 0; i < 4; i++) { + slots._releaseProbeSlot(); + } + assert.strictEqual(slots._activeProbeCount, 0); + notifier.dispose(); + }); + test('reprobes the same active CLI path and observes an in-place replacement', async () => { const results: CliVersionStatus[] = [{ cliPath: '/cli/aspire', diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 0d999ef14ff..66a71625903 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -113,13 +113,20 @@ export class OutdatedCliNotifier implements vscode.Disposable { return false; } - this._activeProbeCount++; + // The releasing probe transfers its slot directly to this waiter. Do not increment here: + // keeping the count at the limit prevents a new arrival from taking the same slot before + // this continuation resumes. return true; } private _releaseProbeSlot(): void { + const waiter = this._probeWaiters.shift(); + if (waiter) { + waiter(); + return; + } + this._activeProbeCount--; - this._probeWaiters.shift()?.(); } dispose(): void { From c492da229f8db310ad59c294033164460006834b Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 25 Aug 2026 16:20:09 -0700 Subject: [PATCH 07/31] Report one-shot Aspire CLI operations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- extension/src/data/AppHostDataRepository.ts | 24 +++++++++++++++---- .../src/test/appHostDataRepository.test.ts | 21 ++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index 69d5e8b2337..fbbf5665942 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -12,12 +12,12 @@ import { nonInteractiveCliEnvironment } from '../utils/environment'; import { getComparisonKey, isAppHostPathUnderFolder, isSameAppHostPath } from '../utils/paths/comparison'; import { FileSystemEntryDescriptor, FileSystemEntryDescriptorIndex, getFileSystemEntryDescriptor } from '../utils/paths/fileSystemIdentity'; import { shortenPath, shortenPaths } from '../utils/paths/shortening'; -import { AppHostDisplayInfo, AspireCliFailedError, AspireCliParseError, DescribeSnapshotJson, ResourceCommandExecutionOutput, ResourceJson, ViewMode } from './appHostCliContracts'; +import { AppHostDisplayInfo, AspireCliFailedError, AspireCliNotInstalledError, AspireCliParseError, DescribeSnapshotJson, ResourceCommandExecutionOutput, ResourceJson, ViewMode } from './appHostCliContracts'; import { AppHostCliRunner, isDescribeUnsupportedOutput, isIncludeDisabledCommandsUnsupportedOutput, oneShotOutputBufferLimit, parseCliJsonOutput, RunCliCommandOptions } from './appHostCliRunner'; import { isMatchingAppHostInstance, isMatchingAppHostPath, isPathInWorkspace } from './appHostPathMatching'; import { AppHostPsPoller } from './appHostPsPoller'; import { filterResourceCommandStatusOutput } from './resourceCommandStatusOutput'; -import { getCliPathTargetForUri } from '../utils/cliPathVariables'; +import { getCliPathTargetForUri, windowCliPathTarget } from '../utils/cliPathVariables'; import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; export * from './appHostCliContracts'; @@ -551,13 +551,14 @@ export class AppHostDataRepository { } try { - const output = await this._cliRunner.runCliCommand(`aspire resource ${commandName}`, args, { + const options = await this._resolveOneShotCli({ timeoutMs: null, stdoutBufferLimit: AppHostDataRepository._oneShotOutputBufferLimit, cancellationToken, env: nonInteractiveCliEnvironment, target, }); + const output = await this._cliRunner.runCliCommand(`aspire resource ${commandName}`, args, options); return { stdout: filterResourceCommandStatusOutput(output.stdout, resourceName, commandName), stderr: output.stderr, @@ -1346,7 +1347,8 @@ export class AppHostDataRepository { } private async _runCliJson(command: string, args: string[], options: RunCliCommandOptions = {}): Promise { - const { stdout } = await this._cliRunner.runCliCommand(command, args, options); + const resolvedOptions = await this._resolveOneShotCli(options); + const { stdout } = await this._cliRunner.runCliCommand(command, args, resolvedOptions); try { return parseCliJsonOutput(stdout); @@ -1355,6 +1357,20 @@ export class AppHostDataRepository { } } + private async _resolveOneShotCli(options: RunCliCommandOptions): Promise { + const target = options.target ?? windowCliPathTarget; + const cliPath = options.cliPath + ?? await this._terminalProvider.getAspireCliExecutablePath(target).catch(error => { + throw new AspireCliNotInstalledError(String(error)); + }); + if (options.cancellationToken?.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + reportCliResolvedForOperation(target, cliPath); + return { ...options, target, cliPath }; + } + private async _fetchAppHostResourcesOnce(appHostPath: string): Promise { const snapshot = await this._runCliJson( 'aspire describe', diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index a35b9668f9c..384b7d510b2 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -1372,6 +1372,8 @@ suite('AppHostDataRepository', () => { spawnStub.onFirstCall().returns(psProcess); spawnStub.onSecondCall().returns(describeProcess); const repository = new AppHostDataRepository(terminalProvider); + const resolutions: Array<{ target: typeof windowCliPathTarget | typeof target; cliPath: string }> = []; + const resolutionSubscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); try { const fetchPromise = repository.fetchAppHostsOnce(); @@ -1421,7 +1423,15 @@ suite('AppHostDataRepository', () => { assert.strictEqual(describeProcess.killed, true); assert.strictEqual(appHosts.length, 1); assert.strictEqual(appHosts[0].resources?.[0].name, 'api'); + assert.deepStrictEqual(resolutions, [{ + target: windowCliPathTarget, + cliPath: '/global/bin/aspire', + }, { + target, + cliPath: '/workspace/bin/aspire', + }]); } finally { + resolutionSubscription.dispose(); repository.dispose(); getWorkspaceFolderStub.restore(); } @@ -1534,6 +1544,12 @@ suite('AppHostDataRepository', () => { const resourceProcess = new TestChildProcess(); spawnStub.returns(resourceProcess); const repository = new AppHostDataRepository(terminalProvider); + const resolutions: Array<{ target: typeof target; cliPath: string }> = []; + const resolutionSubscription = onDidResolveCliForOperation(resolution => { + if (resolution.target.kind === 'workspaceFolder') { + resolutions.push({ target: resolution.target, cliPath: resolution.cliPath }); + } + }); try { const runPromise = repository.runResourceCommand('api', ' /workspace/AppHost.csproj ', 'stop'); @@ -1549,7 +1565,12 @@ suite('AppHostDataRepository', () => { spawnStub.firstCall.args[3].exitCallback(0); await runPromise; + assert.deepStrictEqual(resolutions, [{ + target, + cliPath: '/workspace/bin/aspire', + }]); } finally { + resolutionSubscription.dispose(); repository.dispose(); getWorkspaceFolderStub.restore(); } From 9bfc2ba982827d41760f86e4a0dfb33cc5840c91 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 12:11:02 -0700 Subject: [PATCH 08/31] Generalize outdated Aspire CLI warnings Use the resolved CLI's published channel update recommendation, retain exact operation routing, and bound background probes with independent version and update caches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/loc/xlf/aspire-vscode.xlf | 2 +- extension/package.nls.json | 2 +- .../src/activation/registerCliCommands.ts | 53 +- extension/src/commands/update.ts | 4 +- extension/src/data/AppHostDataRepository.ts | 62 ++- extension/src/data/appHostCliRunner.ts | 15 +- extension/src/loc/strings.ts | 2 +- extension/src/services/AppHostStopper.ts | 5 +- .../cliPathRejectionNotification.e2e.test.ts | 14 +- extension/src/test-e2e/helpers/fixtures.ts | 28 +- extension/src/test/appHostCliRunner.test.ts | 29 ++ .../src/test/appHostDataRepository.test.ts | 47 +- extension/src/test/appHostDiscovery.test.ts | 27 +- extension/src/test/appHostStopper.test.ts | 8 + .../test/aspirePackageRestoreProvider.test.ts | 5 + .../src/test/aspireTerminalProvider.test.ts | 9 + extension/src/test/configInfoProvider.test.ts | 326 ++++++++++-- .../src/test/outdatedCliNotifier.test.ts | 452 ++++++++++------- .../src/test/registerCliCommands.test.ts | 16 +- .../src/test/resourceCommandArguments.test.ts | 5 + .../src/utils/AspirePackageRestoreProvider.ts | 5 +- extension/src/utils/AspireTerminalProvider.ts | 6 + extension/src/utils/appHostDiscovery.ts | 37 +- extension/src/utils/configInfoProvider.ts | 464 ++++++++++++++++-- extension/src/utils/outdatedCliNotifier.ts | 285 ++++++++--- .../views/ResourceCommandArgumentsLoader.ts | 5 +- 26 files changed, 1468 insertions(+), 445 deletions(-) diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 8c0cef5da2a..ab766716917 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -59,7 +59,7 @@ Aspire CLI is not available on PATH. Please install it and restart VS Code. - Aspire CLI {0} at {1} is older than {2}. Update the CLI and the AppHost's Aspire packages to {2} or later to avoid a known startup failure in VS Code. + Aspire CLI {0} at {1} has a newer version available for its current channel: {2}. Aspire Extension diff --git a/extension/package.nls.json b/extension/package.nls.json index bd762dd6af4..0a25c81219a 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -86,7 +86,7 @@ "aspire-vscode.strings.aspireCliPathEnvironmentDescription": "Forwards aspire.aspireCliExecutablePath as AspireCliPath so MSBuild bundle resolution and integrated terminals use the configured Aspire CLI.", "aspire-vscode.strings.configuredCliPathRejected": "The configured Aspire CLI path could not be used, so a different Aspire CLI is running instead: {0}. Check that aspire.aspireCliExecutablePath points to the aspire executable itself and that it runs.", "aspire-vscode.strings.configuredCliPathRejectedOpenSetting": "Open Setting", - "aspire-vscode.strings.outdatedAspireCliWarning": "Aspire CLI {0} at {1} is older than {2}. Update the CLI and the AppHost's Aspire packages to {2} or later to avoid a known startup failure in VS Code.", + "aspire-vscode.strings.outdatedAspireCliWarning": "Aspire CLI {0} at {1} has a newer version available for its current channel: {2}.", "aspire-vscode.strings.updateAspireCliAction": "Update Aspire CLI", "aspire-vscode.strings.installCliPlaceholder": "Select how to install the Aspire CLI", "aspire-vscode.strings.installCliViewAllOptions": "View all installation options", diff --git a/extension/src/activation/registerCliCommands.ts b/extension/src/activation/registerCliCommands.ts index bbb8f878206..8706068f0ff 100644 --- a/extension/src/activation/registerCliCommands.ts +++ b/extension/src/activation/registerCliCommands.ts @@ -31,6 +31,10 @@ interface CommandInvocation { readonly cliPath?: string; } +interface RequiredAppHostCommandInvocation extends CommandInvocation { + readonly appHost: AppHostCommandTarget & { appHostPath: string }; +} + type CommandSource = 'command_palette' | 'tree'; export function registerCliCommands( @@ -45,15 +49,15 @@ export function registerCliCommands( // through tryExecuteCommand itself — the delegated-to command owns its own CLI // availability check and telemetry. const createWithAspireCommandRegistration = registerInstrumentedCommand('aspire-vscode.createWithAspire', 'tree', createWithAspireCommand); - const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand( + const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand( 'aspire-vscode.deploy', terminalProvider, - (_tp, invocation, cliPath) => deployCommand(editorCommandProvider, getRequiredAppHostPath(invocation), invocation.target, cliPath), + (_tp, invocation, cliPath) => deployCommand(editorCommandProvider, invocation.appHost.appHostPath, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider, true))); - const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand( + const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand( 'aspire-vscode.publish', terminalProvider, - (_tp, invocation, cliPath) => publishCommand(editorCommandProvider, getRequiredAppHostPath(invocation), invocation.target, cliPath), + (_tp, invocation, cliPath) => publishCommand(editorCommandProvider, invocation.appHost.appHostPath, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider, true))); const cliDoCommandRegistration = vscode.commands.registerCommand('aspire-vscode.do', () => tryExecuteCommand('aspire-vscode.do', terminalProvider, (_tp, invocation, cliPath) => doCommand(configInfoProvider, editorCommandProvider, invocation.appHost?.appHostPath, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider, true))); const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, (tp, invocation, cliPath) => updateCommand(tp, editorCommandProvider, invocation.appHost ?? {}, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider))); @@ -64,10 +68,10 @@ export function registerCliCommands( (tp, invocation, resolvedCliPath) => updateSelfCommand(tp, invocation.target, resolvedCliPath || undefined), async () => ({ target, cliPath }))); const openTerminalCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openTerminal', () => tryExecuteCommand('aspire-vscode.openTerminal', terminalProvider, (tp, invocation, cliPath) => openTerminalCommand(tp, invocation.target, cliPath), selectCommandInvocation)); - const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', terminalProvider, configureLaunchJsonCommand)); - const settingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.settings', () => tryExecuteCommand('aspire-vscode.settings', terminalProvider, settingsCommand)); + const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', terminalProvider, configureLaunchJsonCommand, selectWindowCommandInvocation)); + const settingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.settings', () => tryExecuteCommand('aspire-vscode.settings', terminalProvider, settingsCommand, selectWindowCommandInvocation)); const openLocalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openLocalSettings', () => tryExecuteCommand('aspire-vscode.openLocalSettings', terminalProvider, (tp, invocation, cliPath) => openLocalSettingsCommand(tp, invocation.target, cliPath), selectCommandInvocation)); - const openGlobalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openGlobalSettings', () => tryExecuteCommand('aspire-vscode.openGlobalSettings', terminalProvider, openGlobalSettingsCommand)); + const openGlobalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openGlobalSettings', () => tryExecuteCommand('aspire-vscode.openGlobalSettings', terminalProvider, openGlobalSettingsCommand, selectWindowCommandInvocation)); const runAppHostCommandRegistration = registerInstrumentedCommand('aspire-vscode.runAppHostCommand', 'editor', (resource?: vscode.Uri) => editorCommandProvider.tryExecuteRunAppHost(true, resource)); const debugAppHostCommandRegistration = registerInstrumentedCommand('aspire-vscode.debugAppHostCommand', 'editor', (resource?: vscode.Uri) => editorCommandProvider.tryExecuteRunAppHost(false, resource)); const runAppHostFromExplorerRegistration = registerInstrumentedCommand('aspire-vscode.runAppHostFromExplorer', 'editor', (resource?: vscode.Uri) => editorCommandProvider.tryExecuteRunAppHost(true, resource, false)); @@ -133,11 +137,25 @@ async function selectCommandInvocation(): Promise { return { target: await selectCommandTarget() }; } +async function selectWindowCommandInvocation(): Promise { + return { target: windowCliPathTarget }; +} + +function selectAppHostCommandInvocation(editorCommandProvider: AspireEditorCommandProvider, requireAppHost: true): Promise; +function selectAppHostCommandInvocation(editorCommandProvider: AspireEditorCommandProvider, requireAppHost?: false): Promise; async function selectAppHostCommandInvocation(editorCommandProvider: AspireEditorCommandProvider, requireAppHost = false): Promise { const appHost = await getAppHostArgs(editorCommandProvider); - if (!appHost.appHostPath && requireAppHost) { - vscode.window.showErrorMessage(noAppHostInWorkspace); - throw new vscode.CancellationError(); + if (requireAppHost) { + const appHostPath = appHost.appHostPath; + if (!appHostPath) { + vscode.window.showErrorMessage(noAppHostInWorkspace); + throw new vscode.CancellationError(); + } + + return { + target: getCliPathTargetForUri(vscode.Uri.file(appHostPath)), + appHost: { ...appHost, appHostPath }, + }; } const target = appHost.appHostPath @@ -146,20 +164,11 @@ async function selectAppHostCommandInvocation(editorCommandProvider: AspireEdito return { target, appHost }; } -function getRequiredAppHostPath(invocation: CommandInvocation): string { - const appHostPath = invocation.appHost?.appHostPath; - if (!appHostPath) { - throw new Error(noAppHostInWorkspace); - } - - return appHostPath; -} - -async function tryExecuteCommand( +async function tryExecuteCommand( commandName: string, terminalProvider: AspireTerminalProvider, - command: (terminalProvider: AspireTerminalProvider, invocation: CommandInvocation, cliPath: string) => Promise, - prepareInvocation: () => Promise = async () => ({ target: windowCliPathTarget }), + command: (terminalProvider: AspireTerminalProvider, invocation: TInvocation, cliPath: string) => Promise, + prepareInvocation: () => Promise, source: CommandSource = 'command_palette', ): Promise { try { diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts index c160d5f31c5..0332567726c 100644 --- a/extension/src/commands/update.ts +++ b/extension/src/commands/update.ts @@ -18,6 +18,8 @@ export async function updateSelfCommand( target: CliPathResolutionTarget = windowCliPathTarget, cliPath?: string, ) { - const options = cliPath ? { target, cliPath } : { target }; + const options = cliPath + ? { target, cliPath, reportCliResolution: false } + : { target, reportCliResolution: false }; await terminalProvider.sendAspireCommandToAspireTerminal('update --self', true, undefined, options); } diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index fbbf5665942..ba8ad81d43b 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -12,12 +12,12 @@ import { nonInteractiveCliEnvironment } from '../utils/environment'; import { getComparisonKey, isAppHostPathUnderFolder, isSameAppHostPath } from '../utils/paths/comparison'; import { FileSystemEntryDescriptor, FileSystemEntryDescriptorIndex, getFileSystemEntryDescriptor } from '../utils/paths/fileSystemIdentity'; import { shortenPath, shortenPaths } from '../utils/paths/shortening'; -import { AppHostDisplayInfo, AspireCliFailedError, AspireCliNotInstalledError, AspireCliParseError, DescribeSnapshotJson, ResourceCommandExecutionOutput, ResourceJson, ViewMode } from './appHostCliContracts'; +import { AppHostDisplayInfo, AspireCliFailedError, AspireCliParseError, DescribeSnapshotJson, ResourceCommandExecutionOutput, ResourceJson, ViewMode } from './appHostCliContracts'; import { AppHostCliRunner, isDescribeUnsupportedOutput, isIncludeDisabledCommandsUnsupportedOutput, oneShotOutputBufferLimit, parseCliJsonOutput, RunCliCommandOptions } from './appHostCliRunner'; import { isMatchingAppHostInstance, isMatchingAppHostPath, isPathInWorkspace } from './appHostPathMatching'; import { AppHostPsPoller } from './appHostPsPoller'; import { filterResourceCommandStatusOutput } from './resourceCommandStatusOutput'; -import { getCliPathTargetForUri, windowCliPathTarget } from '../utils/cliPathVariables'; +import { getCliPathTargetForUri } from '../utils/cliPathVariables'; import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; export * from './appHostCliContracts'; @@ -128,6 +128,7 @@ export class AppHostDataRepository { private _workspaceAppHostDiscoveryProgressResolve: (() => void) | undefined; private _workspaceAppHostDiscoveryCancellationSource: vscode.CancellationTokenSource | undefined; private readonly _appHostDiscoveryChangeDisposable: vscode.Disposable; + private readonly _appHostDiscoveryCliResolutionDisposable: vscode.Disposable; private readonly _workspaceFoldersChangeDisposable: vscode.Disposable; private readonly _appHostDiscoveryService: AppHostDiscoveryService; private readonly _ownsAppHostDiscoveryService: boolean; @@ -172,6 +173,13 @@ export class AppHostDataRepository { this._fetchWorkspaceAppHost(); } }); + this._appHostDiscoveryCliResolutionDisposable = this._appHostDiscoveryService.onDidResolveCli + ? this._appHostDiscoveryService.onDidResolveCli(({ workspaceFolder, cliPath }) => { + if (this._dataActive) { + reportCliResolvedForOperation(getCliPathTargetForUri(workspaceFolder.uri), cliPath); + } + }) + : vscode.Disposable.from(); this._workspaceFoldersChangeDisposable = vscode.workspace.onDidChangeWorkspaceFolders(event => { this._removeWorkspaceFolderCandidates(event.removed); for (const workspaceFolder of event.removed) { @@ -187,9 +195,10 @@ export class AppHostDataRepository { this._configChangeDisposable = vscode.workspace.onDidChangeConfiguration(e => { const cliPathChanged = e.affectsConfiguration('aspire.aspireCliExecutablePath'); if (cliPathChanged) { + this._cancelWorkspaceAppHostDiscovery(); this._markWorkspaceAppHostDiscoveryPending({ preserveCandidates: true }); - this._fetchWorkspaceAppHost({ forceRefresh: true }); if (this._dataActive) { + this._fetchWorkspaceAppHost({ forceRefresh: true }); this._psPoller.startPsPolling(); this._stopAllDescribes(); this._reconcileDescribes(); @@ -300,7 +309,7 @@ export class AppHostDataRepository { this._hasEverBeenDataActive = true; } if (becameDataActive) { - this._reportWorkspaceDiscoveryCliResolutions(); + this._handleDataSourcesActivated(); } this._syncPolling(resumedFromInactive); } @@ -316,7 +325,7 @@ export class AppHostDataRepository { const resumedFromInactive = becameDataActive && this._hasEverBeenDataActive; this._hasEverBeenDataActive = true; if (becameDataActive) { - this._reportWorkspaceDiscoveryCliResolutions(); + this._handleDataSourcesActivated(); } this._syncPolling(resumedFromInactive); @@ -358,7 +367,7 @@ export class AppHostDataRepository { this._hasEverBeenDataActive = true; } if (becameDataActive) { - this._reportWorkspaceDiscoveryCliResolutions(); + this._handleDataSourcesActivated(); } // Re-scope the displayed AppHosts against the new open-tab set right away. this._handlePsSnapshot(this._appHosts, { force: true }); @@ -551,14 +560,14 @@ export class AppHostDataRepository { } try { - const options = await this._resolveOneShotCli({ + const output = await this._cliRunner.runCliCommand(`aspire resource ${commandName}`, args, { timeoutMs: null, stdoutBufferLimit: AppHostDataRepository._oneShotOutputBufferLimit, cancellationToken, env: nonInteractiveCliEnvironment, target, + reportCliResolution: true, }); - const output = await this._cliRunner.runCliCommand(`aspire resource ${commandName}`, args, options); return { stdout: filterResourceCommandStatusOutput(output.stdout, resourceName, commandName), stderr: output.stderr, @@ -587,6 +596,7 @@ export class AppHostDataRepository { this._cancelWorkspaceAppHostDiscovery(); this._configChangeDisposable.dispose(); this._appHostDiscoveryChangeDisposable.dispose(); + this._appHostDiscoveryCliResolutionDisposable.dispose(); this._workspaceFoldersChangeDisposable.dispose(); this._psPollerDisposable.dispose(); this._psPoller.dispose(); @@ -598,8 +608,19 @@ export class AppHostDataRepository { private _reportWorkspaceDiscoveryCliResolutions(): void { for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { - this._appHostDiscoveryService.reportResolvedCliForWorkspace?.(workspaceFolder); + const cliPath = this._appHostDiscoveryService.getResolvedCliPath?.(workspaceFolder); + if (cliPath) { + reportCliResolvedForOperation(getCliPathTargetForUri(workspaceFolder.uri), cliPath); + } + } + } + + private _handleDataSourcesActivated(): void { + if (!this._workspaceAppHostDiscoveryComplete && !this._workspaceAppHostDiscoveryInProgress) { + this._fetchWorkspaceAppHost({ forceRefresh: true }); + return; } + this._reportWorkspaceDiscoveryCliResolutions(); } // ── PS polling lifecycle ── @@ -732,8 +753,7 @@ export class AppHostDataRepository { folderCandidates.workspaceFolder, options?.forceRefresh, cancellationSource.token, - candidate => onIncrementalCandidate(folderCandidates, candidate), - this._dataActive); + candidate => onIncrementalCandidate(folderCandidates, candidate)); } catch (error) { folderCandidates.candidates = []; errors[workspaceFolderIndex] = { @@ -1347,8 +1367,10 @@ export class AppHostDataRepository { } private async _runCliJson(command: string, args: string[], options: RunCliCommandOptions = {}): Promise { - const resolvedOptions = await this._resolveOneShotCli(options); - const { stdout } = await this._cliRunner.runCliCommand(command, args, resolvedOptions); + const { stdout } = await this._cliRunner.runCliCommand(command, args, { + ...options, + reportCliResolution: true, + }); try { return parseCliJsonOutput(stdout); @@ -1357,20 +1379,6 @@ export class AppHostDataRepository { } } - private async _resolveOneShotCli(options: RunCliCommandOptions): Promise { - const target = options.target ?? windowCliPathTarget; - const cliPath = options.cliPath - ?? await this._terminalProvider.getAspireCliExecutablePath(target).catch(error => { - throw new AspireCliNotInstalledError(String(error)); - }); - if (options.cancellationToken?.isCancellationRequested) { - throw new vscode.CancellationError(); - } - - reportCliResolvedForOperation(target, cliPath); - return { ...options, target, cliPath }; - } - private async _fetchAppHostResourcesOnce(appHostPath: string): Promise { const snapshot = await this._runCliJson( 'aspire describe', diff --git a/extension/src/data/appHostCliRunner.ts b/extension/src/data/appHostCliRunner.ts index f0fd7ee57ec..524d8865153 100644 --- a/extension/src/data/appHostCliRunner.ts +++ b/extension/src/data/appHostCliRunner.ts @@ -8,6 +8,7 @@ import { isNoLogoUnsupportedOutput, noLogoOption, removeRootNoLogoOption } from import { AspireCliFailedError, AspireCliNotInstalledError } from './appHostCliContracts'; import { normalizeResourceCommandStatusLine } from './resourceCommandStatusOutput'; import { CliPathResolutionTarget, windowCliPathTarget } from '../utils/cliPathVariables'; +import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; export const oneShotOutputBufferLimit = 64 * 1024; @@ -18,6 +19,7 @@ export interface RunCliCommandOptions { env?: { name: string; value: string }[]; target?: CliPathResolutionTarget; cliPath?: string; + reportCliResolution?: boolean; } export class AppHostCliRunner implements vscode.Disposable { @@ -78,14 +80,18 @@ export class AppHostCliRunner implements vscode.Disposable { } async runCliCommand(command: string, args: string[], options: RunCliCommandOptions = {}): Promise<{ stdout: string; stderr: string }> { + const target = options.target ?? windowCliPathTarget; const cliPath = options.cliPath - ?? await this._terminalProvider.getAspireCliExecutablePath(options.target ?? windowCliPathTarget).catch(error => { + ?? await this._terminalProvider.getAspireCliExecutablePath(target).catch(error => { throw new AspireCliNotInstalledError(String(error)); }); if (options.cancellationToken?.isCancellationRequested) { throw new vscode.CancellationError(); } + if (options.reportCliResolution) { + reportCliResolvedForOperation(target, cliPath); + } const invocationArgs = this.normalizeNoLogoArgs(cliPath, args); return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { @@ -144,7 +150,12 @@ export class AppHostCliRunner implements vscode.Disposable { const retryArgs = this.tryGetNoLogoRetryArgs(cliPath, invocationArgs, stdout.value, stderr.value, command); if (retryArgs) { settle(() => { - this.runCliCommand(command, retryArgs, options).then(resolve, reject); + this.runCliCommand(command, retryArgs, { + ...options, + cliPath, + target, + reportCliResolution: false, + }).then(resolve, reject); }); return; } diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index d186b66717f..47c4461f15d 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -185,7 +185,7 @@ export const failedToGetConfigInfo = (exitCode: number) => vscode.l10n.t('Failed export const failedToParseConfigInfo = (error: any) => vscode.l10n.t('Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); export const errorGettingConfigInfo = (error: any) => vscode.l10n.t('Error getting Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); export const configInfoTimedOut = (seconds: number) => vscode.l10n.t('Aspire config info timed out after {0} seconds.', seconds); -export const outdatedAspireCliWarning = (version: string, cliPath: string, minimumVersion: string) => vscode.l10n.t("Aspire CLI {0} at {1} is older than {2}. Update the CLI and the AppHost's Aspire packages to {2} or later to avoid a known startup failure in VS Code.", version, cliPath, minimumVersion); +export const outdatedAspireCliWarning = (version: string, cliPath: string, recommendedVersion: string) => vscode.l10n.t('Aspire CLI {0} at {1} has a newer version available for its current channel: {2}.', version, cliPath, recommendedVersion); export const updateAspireCliAction = vscode.l10n.t({ message: 'Update Aspire CLI', comment: 'Button label that updates the installed Aspire CLI.' }); export const invalidLaunchConfiguration = (projectPath: string) => vscode.l10n.t('Invalid launch configuration for {0}.', projectPath); export const browserDisplayName = (url: string) => vscode.l10n.t('Browser: {0}', url); diff --git a/extension/src/services/AppHostStopper.ts b/extension/src/services/AppHostStopper.ts index 6101cf0273e..8961365651a 100644 --- a/extension/src/services/AppHostStopper.ts +++ b/extension/src/services/AppHostStopper.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { spawnCliProcess, terminateCliProcess } from '../utils/process/cliProcess'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { getCliPathTargetForUri } from '../utils/cliPathVariables'; +import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; const maxRetainedStderrLength = 16 * 1024; @@ -11,10 +12,12 @@ export async function stopExternalAppHost( cancellationToken: vscode.CancellationToken, ): Promise { - const cliPath = await terminalProvider.getAspireCliExecutablePath(getCliPathTargetForUri(vscode.Uri.file(appHostPath))); + const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath)); + const cliPath = await terminalProvider.getAspireCliExecutablePath(target); if (cancellationToken.isCancellationRequested) { throw new vscode.CancellationError(); } + reportCliResolvedForOperation(target, cliPath); await new Promise((resolve, reject) => { let settled = false; diff --git a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts index 5f502bb0b08..098e08c1faa 100644 --- a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts +++ b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts @@ -12,7 +12,7 @@ import { setTerminalCommandExecutionSuppressedForE2E, touchPrimaryAppHostProject, waitForCliWrapperInvocation, - writeOutdatedCliWarningWrapper, + writeCliUpdateWarningWrapper, writeTrackedStreamingDiscoveryCliWrapper, writeWorkspaceCliPath, } from './helpers/fixtures'; @@ -23,8 +23,8 @@ import { openAspireView, waitForNotificationMessage, waitForWorkbenchText } from // Mirrors configuredCliPathRejected in src/loc/strings.ts. const rejectionNotificationText = 'The configured Aspire CLI path could not be used'; const openSettingActionText = 'Open Setting'; -const outdatedCliWarningText = 'Aspire CLI 13.4.9 at '; -const outdatedCliGuidanceText = "is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later"; +const cliUpdateWarningText = 'Aspire CLI 13.5.0 at '; +const cliUpdateGuidanceText = 'has a newer version available for its current channel: 13.6.0'; const updateCliActionText = 'Update Aspire CLI'; suite('Configured CLI path rejection E2E', function () { @@ -108,7 +108,7 @@ suite('Configured CLI path rejection E2E', function () { test('warns when active Aspire use resolves an outdated CLI and routes the update action', async () => { await openAspireView(); await waitForRepositoryIdle(); - const wrapper = writeOutdatedCliWarningWrapper(); + const wrapper = writeCliUpdateWarningWrapper(); cleanupOutdatedCliWrapper = wrapper.cleanup; await setE2eCliPathForE2E(wrapper.cliPath); await setTerminalCommandExecutionSuppressedForE2E(true); @@ -121,12 +121,12 @@ suite('Configured CLI path rejection E2E', function () { }); await waitForCommandOutcome('aspire-vscode.openTerminal', 'success', 60_000, beforeOpenTerminal); - const notification = await waitForNotificationMessage(outdatedCliWarningText, 60_000); + const notification = await waitForNotificationMessage(cliUpdateWarningText, 60_000); const message = await notification.getMessage(); await VSBrowser.instance.takeScreenshot('outdated-aspire-cli-warning').catch(() => undefined); - assert.ok(message.includes(outdatedCliWarningText), `Unexpected warning message: ${message}`); + assert.ok(message.includes(cliUpdateWarningText), `Unexpected warning message: ${message}`); assert.ok(message.includes(wrapper.cliPath), `Warning did not identify the exact CLI '${wrapper.cliPath}': ${message}`); - assert.ok(message.includes(outdatedCliGuidanceText), `Warning did not include the expected update guidance: ${message}`); + assert.ok(message.includes(cliUpdateGuidanceText), `Warning did not include the expected update guidance: ${message}`); const beforeTerminalCommand = getTerminalCommandCount(); await notification.takeAction(updateCliActionText); diff --git a/extension/src/test-e2e/helpers/fixtures.ts b/extension/src/test-e2e/helpers/fixtures.ts index f5745037b5a..6eae5725801 100644 --- a/extension/src/test-e2e/helpers/fixtures.ts +++ b/extension/src/test-e2e/helpers/fixtures.ts @@ -442,10 +442,12 @@ export function writeTrackedStreamingDiscoveryCliWrapper(delayMs = 4_000, initia return { cliPath, invocationLogPath }; } -export function writeOutdatedCliWarningWrapper(version = '13.4.9'): { cliPath: string; cleanup: () => void } { - const name = 'aspire-outdated-cli-warning'; +export function writeCliUpdateWarningWrapper(version = '13.5.0', recommendedVersion = '13.6.0'): { cliPath: string; cleanup: () => void } { + const name = 'aspire-cli-update-warning'; const cliPath = writeCliWrapper(name, { versionOutput: version, + recommendedVersion, + identityChannel: version.includes('-') ? 'daily' : 'stable', }); const scriptPath = path.join(path.dirname(cliPath), `${name}.js`); @@ -973,6 +975,8 @@ function writeCliWrapper( psSnapshotAppHostPath?: string; psSnapshotAppHostPid?: number; versionOutput?: string; + recommendedVersion?: string; + identityChannel?: string; }, wrapperDirectory = path.join(getWorkspaceRoot(), '.e2e-cli-wrappers'), ): string { @@ -1008,6 +1012,26 @@ ${options.versionOutput === undefined process.exit(0); } +`} +${options.recommendedVersion === undefined + ? '' + : `if (args[0] === 'doctor' && args.includes('--format') && args[args.indexOf('--format') + 1] === 'json') { + console.log(JSON.stringify({ + checks: [{ + name: 'cli-version', + metadata: { + currentVersion: ${JSON.stringify(options.versionOutput)}, + latestVersion: ${JSON.stringify(options.recommendedVersion)}, + identityChannel: ${JSON.stringify(options.identityChannel)}, + latestVersionChannel: ${JSON.stringify(options.recommendedVersion.includes('-') ? 'prerelease' : 'stable')}, + }, + }], + summary: { passed: 0, warnings: 1, failed: 0 }, + installations: [], + })); + process.exit(0); +} + `} if (args.includes('--include-disabled-commands')) { console.error('simulated old CLI does not support --include-disabled-commands'); diff --git a/extension/src/test/appHostCliRunner.test.ts b/extension/src/test/appHostCliRunner.test.ts index 9481fb39fc0..b6580fdca4e 100644 --- a/extension/src/test/appHostCliRunner.test.ts +++ b/extension/src/test/appHostCliRunner.test.ts @@ -7,6 +7,7 @@ import { AspireCliFailedError } from '../data/appHostCliContracts'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import * as cliModule from '../utils/process/cliProcess'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; class TestChildProcess extends EventEmitter { killed = false; @@ -162,6 +163,34 @@ suite('data/appHostCliRunner tests', () => { } }); + test('reports an exact CLI only when the caller marks it as an operation', async () => { + const cliProcess = new TestChildProcess(); + spawnStub.callsFake((_provider: unknown, _cliPath: string, _args: string[], options: cliModule.SpawnProcessOptions) => { + cliProcess.exitCode = 0; + options.processExitCallback?.(0); + return cliProcess; + }); + const resolutions: string[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); + const runner = new AppHostCliRunner(terminalProvider); + + try { + await runner.runCliCommand('pipeline steps', ['do', '--list-steps'], { + cliPath: '/cli/aspire', + }); + await runner.runCliCommand('describe', ['describe'], { + cliPath: '/cli/aspire', + reportCliResolution: true, + }); + + assert.deepStrictEqual(resolutions, ['/cli/aspire']); + } + finally { + subscription.dispose(); + runner.dispose(); + } + }); + test('isolates nologo fallback by concrete CLI path', async () => { const folderA: vscode.WorkspaceFolder = { uri: vscode.Uri.file('/workspace/a'), diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 384b7d510b2..586c1950400 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -6267,39 +6267,48 @@ suite('AppHostDataRepository global polling', () => { }; const workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([workspaceFolder]); const candidateChange = new vscode.EventEmitter(); - const reportResolvedCliForWorkspace = sinon.stub(); + const cliResolution = new vscode.EventEmitter<{ workspaceFolder: vscode.WorkspaceFolder; cliPath: string }>(); + const getResolvedCliPath = sinon.stub().returns('/workspace/aspire'); const discoveryService = { discover: sinon.stub().resolves([]), - reportResolvedCliForWorkspace, + getResolvedCliPath, + onDidResolveCli: cliResolution.event, onDidChangeCandidates: candidateChange.event, dispose: () => { }, } as unknown as AppHostDiscoveryService; const repository = new AppHostDataRepository(terminalProvider, discoveryService); + const resolutions: string[] = []; + const resolutionSubscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); try { repository.activate(); await waitForMicrotasks(); - assert.strictEqual(reportResolvedCliForWorkspace.callCount, 0); + assert.deepStrictEqual(resolutions, []); repository.setPanelVisible(true); - assert.ok(reportResolvedCliForWorkspace.calledOnceWithExactly(workspaceFolder)); + assert.deepStrictEqual(resolutions, ['/workspace/aspire']); + + cliResolution.fire({ workspaceFolder, cliPath: '/workspace/aspire-next' }); + assert.deepStrictEqual(resolutions, ['/workspace/aspire', '/workspace/aspire-next']); repository.setAppHostFilesOpen(['/workspace/AppHost.csproj']); - assert.strictEqual(reportResolvedCliForWorkspace.callCount, 1); + assert.strictEqual(resolutions.length, 2); repository.setPanelVisible(false); repository.setAppHostFilesOpen([]); repository.setPanelVisible(true); - assert.strictEqual(reportResolvedCliForWorkspace.callCount, 2); + assert.strictEqual(resolutions.length, 3); } finally { + resolutionSubscription.dispose(); repository.dispose(); candidateChange.dispose(); + cliResolution.dispose(); workspaceFoldersStub.restore(); } }); - test('an active scoped CLI path change forces discovery to report the new exact path', async () => { + test('a scoped CLI path change refreshes discovery only after data becomes active', async () => { const workspaceFolder: vscode.WorkspaceFolder = { uri: vscode.Uri.file('/workspace'), name: 'workspace', @@ -6310,32 +6319,48 @@ suite('AppHostDataRepository global polling', () => { const onDidChangeConfigurationStub = sinon.stub(vscode.workspace, 'onDidChangeConfiguration') .callsFake(listener => configurationChange.event(listener)); const candidateChange = new vscode.EventEmitter(); - const discover = sinon.stub().resolves([]); + const cliResolution = new vscode.EventEmitter<{ workspaceFolder: vscode.WorkspaceFolder; cliPath: string }>(); + const discover = sinon.stub().callsFake(async (_workspaceFolder, forceRefresh) => { + cliResolution.fire({ + workspaceFolder, + cliPath: forceRefresh ? '/cli/second/aspire' : '/cli/first/aspire', + }); + return []; + }); const discoveryService = { discover, - reportResolvedCliForWorkspace: sinon.stub(), + getResolvedCliPath: sinon.stub().returns('/cli/first/aspire'), + onDidResolveCli: cliResolution.event, onDidChangeCandidates: candidateChange.event, dispose: () => { }, } as unknown as AppHostDiscoveryService; const repository = new AppHostDataRepository(terminalProvider, discoveryService); + const resolutions: string[] = []; + const resolutionSubscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); try { repository.activate(); - repository.setPanelVisible(true); await waitForCondition(() => discover.callCount > 0, 'initial workspace discovery did not complete'); discover.resetHistory(); + resolutions.length = 0; configurationChange.fire({ affectsConfiguration: section => section === 'aspire.aspireCliExecutablePath', }); + await waitForMicrotasks(); + assert.strictEqual(discover.callCount, 0); + + repository.setPanelVisible(true); await waitForCondition(() => discover.callCount > 0, 'CLI path change did not refresh workspace discovery'); assert.strictEqual(discover.lastCall.args[1], true); - assert.strictEqual(discover.lastCall.args[4], true); + assert.ok(resolutions.includes('/cli/second/aspire')); } finally { + resolutionSubscription.dispose(); repository.dispose(); candidateChange.dispose(); + cliResolution.dispose(); configurationChange.dispose(); onDidChangeConfigurationStub.restore(); workspaceFoldersStub.restore(); diff --git a/extension/src/test/appHostDiscovery.test.ts b/extension/src/test/appHostDiscovery.test.ts index fc544086c91..d32b1252b45 100644 --- a/extension/src/test/appHostDiscovery.test.ts +++ b/extension/src/test/appHostDiscovery.test.ts @@ -15,7 +15,6 @@ import { lsJsonStreamCapability } from '../types/configInfo'; import { __resetCommonPropertiesForTests, __setReporterForTests } from '../utils/telemetry'; import { appHostDiscoveryFindFilesMaxResults } from '../utils/workspaceFileSearch'; import { workspaceFolderCliPathTarget, getCliPathTargetKey } from '../utils/cliPathVariables'; -import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; import { removeDirectorySafely } from './testHelpers'; interface RecordedEvent { @@ -209,7 +208,7 @@ suite('AppHost discovery', () => { sandbox.restore(); }); - test('reports only active discovery CLI resolutions and observes a scoped path change', async () => { + test('publishes resolved CLI data and observes a scoped path change', async () => { stubFileSystemWatchers(sandbox); const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); let cliPath = buildPath('cli', 'first', 'aspire'); @@ -221,29 +220,19 @@ suite('AppHost discovery', () => { emitLsOutput(options, []); return { kill: () => { } } as any; }); - const resolutions: Array<{ targetKey: string; cliPath: string }> = []; - const resolutionSubscription = onDidResolveCliForOperation(resolution => resolutions.push({ - targetKey: getCliPathTargetKey(resolution.target), - cliPath: resolution.cliPath, - })); + const resolutions: string[] = []; const service = new AppHostDiscoveryService(terminalProvider); + const resolutionSubscription = service.onDidResolveCli(resolution => resolutions.push(resolution.cliPath)); try { await service.discover(workspaceFolder); - assert.deepStrictEqual(resolutions, []); - - service.reportResolvedCliForWorkspace(workspaceFolder); - assert.deepStrictEqual(resolutions, [{ - targetKey: getCliPathTargetKey(workspaceFolderCliPathTarget(workspaceFolder)), - cliPath, - }]); + assert.deepStrictEqual(resolutions, [cliPath]); + assert.strictEqual(service.getResolvedCliPath(workspaceFolder), cliPath); cliPath = buildPath('cli', 'second', 'aspire'); - await service.discover(workspaceFolder, true, undefined, undefined, true); - assert.deepStrictEqual(resolutions.at(-1), { - targetKey: getCliPathTargetKey(workspaceFolderCliPathTarget(workspaceFolder)), - cliPath, - }); + await service.discover(workspaceFolder, true); + assert.strictEqual(resolutions.at(-1), cliPath); + assert.strictEqual(service.getResolvedCliPath(workspaceFolder), cliPath); } finally { resolutionSubscription.dispose(); diff --git a/extension/src/test/appHostStopper.test.ts b/extension/src/test/appHostStopper.test.ts index 4d63237c27f..3ad66201e23 100644 --- a/extension/src/test/appHostStopper.test.ts +++ b/extension/src/test/appHostStopper.test.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { stopExternalAppHost } from '../services/AppHostStopper'; import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; suite('AppHostStopper', () => { test('waits for aspire stop to exit successfully', async () => { @@ -53,6 +54,8 @@ suite('AppHostStopper', () => { createEnvironment: () => ({}), sendAspireCommandToAspireTerminal: async () => { }, } as unknown as AspireTerminalProvider; + const resolutions: Array<{ target: unknown; cliPath: string }> = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); try { const stopping = stopExternalAppHost( @@ -62,12 +65,17 @@ suite('AppHostStopper', () => { await new Promise(resolve => setImmediate(resolve)); assert.ok(getAspireCliExecutablePathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); + assert.deepStrictEqual(resolutions, [{ + target: workspaceFolderCliPathTarget(folder), + cliPath: '/repo/bin/aspire', + }]); childState.exitCode = 0; child.emit('close', 0); await stopping; } finally { + subscription.dispose(); getWorkspaceFolderStub.restore(); (nodeChildProcess.spawn as sinon.SinonStub).restore(); } diff --git a/extension/src/test/aspirePackageRestoreProvider.test.ts b/extension/src/test/aspirePackageRestoreProvider.test.ts index a0137900cb6..def5f188cff 100644 --- a/extension/src/test/aspirePackageRestoreProvider.test.ts +++ b/extension/src/test/aspirePackageRestoreProvider.test.ts @@ -8,6 +8,7 @@ import { AspirePackageRestoreProvider } from '../utils/AspirePackageRestoreProvi import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import * as cliProcessModule from '../utils/process/cliProcess'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; suite('AspirePackageRestoreProvider', () => { let sandbox: sinon.SinonSandbox; @@ -32,6 +33,8 @@ suite('AspirePackageRestoreProvider', () => { }); return childProcess as unknown as ChildProcessWithoutNullStreams; }); + const resolutions: string[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); try { await (provider as any)._runRestore(configUri, folder.uri.fsPath, 'aspire.config.json'); @@ -42,7 +45,9 @@ suite('AspirePackageRestoreProvider', () => { '/repo/workspace/bin/aspire', ['restore'], sinon.match({ workingDirectory: folder.uri.fsPath }))); + assert.deepStrictEqual(resolutions, ['/repo/workspace/bin/aspire']); } finally { + subscription.dispose(); provider.dispose(); } }); diff --git a/extension/src/test/aspireTerminalProvider.test.ts b/extension/src/test/aspireTerminalProvider.test.ts index 2d78e2f949d..388b922d8b3 100644 --- a/extension/src/test/aspireTerminalProvider.test.ts +++ b/extension/src/test/aspireTerminalProvider.test.ts @@ -11,6 +11,7 @@ import { createWorkspaceFolder, removeDirectorySafely } from './testHelpers'; import { EnvironmentVariables } from '../utils/environment'; import { extensionLogOutputChannel } from '../utils/logging'; import { terminalCommandArgumentControlCharacters, terminalCommandUnsafeLiteral } from '../loc/strings'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; suite('AspireTerminalProvider tests', () => { let terminalProvider: AspireTerminalProvider; @@ -303,6 +304,8 @@ suite('AspireTerminalProvider tests', () => { show: () => { }, dispose: () => { }, } as unknown as vscode.Terminal); + const resolutions: string[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); try { await terminalProvider.sendAspireCommandToAspireTerminal('logs', false, undefined, { target }); @@ -313,8 +316,10 @@ suite('AspireTerminalProvider tests', () => { assert.ok(executedCommand?.includes(cliPath), executedCommand); assert.ok(executedCommand?.endsWith(' logs'), executedCommand); assert.ok(createEnvironmentStub.calledOnceWith(undefined, undefined, undefined, cliPath)); + assert.deepStrictEqual(resolutions, [cliPath]); } finally { + subscription.dispose(); createTerminalStub.restore(); createEnvironmentStub.restore(); } @@ -336,6 +341,8 @@ suite('AspireTerminalProvider tests', () => { show: () => { }, dispose: () => { }, } as unknown as vscode.Terminal); + const resolutions: string[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); try { await terminalProvider.sendAspireCommandToAspireTerminal('new', false, undefined, { target, cliPath }); @@ -343,8 +350,10 @@ suite('AspireTerminalProvider tests', () => { assert.strictEqual(resolveCliPathStub.called, false); assert.ok(executedCommand?.includes(cliPath), executedCommand); assert.strictEqual(createEnvironmentStub.firstCall.args[3], cliPath); + assert.deepStrictEqual(resolutions, []); } finally { + subscription.dispose(); createTerminalStub.restore(); createEnvironmentStub.restore(); } diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts index 00555059a9f..4179c627ced 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -1,10 +1,13 @@ import * as assert from 'assert'; import nodeChildProcess = require('child_process'); import { EventEmitter } from 'events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import type { ChildProcessWithoutNullStreams } from 'child_process'; -import { ConfigInfoProvider, getConfigInfo, parseConfigInfoOutput } from '../utils/configInfoProvider'; +import { ConfigInfoProvider, getConfigInfo, parseCliUpdateRecommendationOutput, parseConfigInfoOutput } from '../utils/configInfoProvider'; import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import * as cliModule from '../utils/process/cliProcess'; import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; @@ -24,6 +27,31 @@ function emitConfigInfo(options: cliModule.SpawnProcessOptions | undefined, capa options?.exitCallback?.(0); } +function createDoctorVersionOutput( + currentVersion: string, + latestVersion?: string, + updateCheckError?: string, + identityChannel: string | null = 'stable', + latestVersionChannel: string | null = latestVersion + ? (latestVersion.includes('-') ? 'prerelease' : 'stable') + : null, +): string { + return JSON.stringify({ + checks: [{ + name: 'cli-version', + metadata: { + currentVersion, + latestVersion, + updateCheckError, + ...(identityChannel === null ? {} : { identityChannel }), + ...(latestVersionChannel === null ? {} : { latestVersionChannel }), + }, + }], + summary: { passed: 0, warnings: 0, failed: 0 }, + installations: [], + }); +} + suite('configInfoProvider tests', () => { teardown(() => sinon.restore()); @@ -267,56 +295,228 @@ suite('configInfoProvider tests', () => { assert.deepStrictEqual(spawnStub.secondCall.args[2], ['config', 'info', '--json']); }); - test('getCliVersionStatus enforces the 13.5 stable boundary without running config info', async () => { + test('parseCliUpdateRecommendationOutput accepts stable and prerelease recommendations', () => { + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.5.0', '13.6.0')), + { status: 'available', currentVersion: '13.5.0', version: '13.6.0' }); + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.6.0-preview.2', '13.7.0-preview.1', undefined, 'daily')), + { status: 'available', currentVersion: '13.6.0-preview.2', version: '13.7.0-preview.1' }); + // Doctor reports only one stable-first recommendation. Ignore that cross-lane result for a + // prerelease install even though a simultaneously newer prerelease is not observable. + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.6.0-preview.2', '13.6.0', undefined, 'daily')), + { status: 'none', currentVersion: '13.6.0-preview.2' }); + // The CLI's stable update rule cannot produce this payload, but reject it defensively so a + // stable installation is never nudged onto a prerelease channel. + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.6.0', '13.7.0-preview.1')), + { status: 'none', currentVersion: '13.6.0' }); + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.6.0')), + { status: 'none', currentVersion: '13.6.0' }); + for (const identityChannel of ['local', 'pr-19670', 'run-42', 'default', 'future']) { + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.6.0-dev', '13.7.0-preview.1', undefined, identityChannel)), + { status: 'ineligible', currentVersion: '13.6.0-dev' }); + } + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.6.0-preview.1', '13.7.0-preview.1', undefined, null)), + { status: 'ineligible', currentVersion: '13.6.0-preview.1' }); + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.6.0-preview.1', '13.7.0-preview.1', undefined, 'daily', null)), + { status: 'available', currentVersion: '13.6.0-preview.1', version: '13.7.0-preview.1' }); + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.6.0-dev', '13.7.0', undefined, 'local'), 'stable'), + { status: 'ineligible', currentVersion: '13.6.0-dev' }); + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.6.0-dev', undefined, 'offline', 'local')), + { status: 'ineligible', currentVersion: '13.6.0-dev' }); + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.5.0', undefined, 'offline')), + { status: 'unavailable' }); + }); + + test('parseCliUpdateRecommendationOutput accepts captured Aspire doctor payloads', () => { + // Captured from the repo-built 13.6.0-dev CLI. Doctor writes progress to stderr and emits + // this check inside its stdout JSON response. + const prereleaseUpdateOutput = JSON.stringify({ + checks: [{ + category: 'aspire', + name: 'cli-version', + status: 'warning', + message: 'Aspire CLI version 13.6.0-dev (channel: local) is out of date. Latest version is 13.6.0-preview.1.26426.1 (channel: prerelease)', + fix: "Run 'aspire update' to update Aspire CLI.", + metadata: { + currentVersion: '13.6.0-dev', + latestVersion: '13.6.0-preview.1.26426.1', + updateCommand: 'aspire update', + identityChannel: 'local', + latestVersionChannel: 'prerelease', + }, + }], + summary: { passed: 6, warnings: 1, failed: 0 }, + installations: [], + }); + assert.deepStrictEqual( + parseCliUpdateRecommendationOutput(prereleaseUpdateOutput), + { status: 'ineligible', currentVersion: '13.6.0-dev' }); + + // Captured from an up-to-date installed 13.5.2 stable CLI. + const currentStableOutput = JSON.stringify({ + checks: [{ + category: 'aspire', + name: 'cli-version', + status: 'pass', + message: 'Aspire CLI version 13.5.2 (channel: stable)', + metadata: { + currentVersion: '13.5.2', + identityChannel: 'stable', + }, + }], + summary: { passed: 7, warnings: 0, failed: 0 }, + installations: [], + }); + assert.deepStrictEqual( + parseCliUpdateRecommendationOutput(currentStableOutput), + { status: 'none', currentVersion: '13.5.2' }); + }); + + test('getCliIdentity follows environment then install-sidecar channel precedence', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-identity-')); + const cliPath = path.join(directory, process.platform === 'win32' ? 'aspire.exe' : 'aspire'); + fs.writeFileSync(path.join(directory, '.aspire-install.json'), JSON.stringify({ + source: 'script', + channel: 'staging', + })); + let environmentChannel: string | undefined = 'daily'; const terminalProvider = { - getAspireCliExecutablePath: async () => '/unused/aspire', - createEnvironment: () => ({}), + getAspireCliExecutablePath: async () => cliPath, + createEnvironment: () => environmentChannel ? { ASPIRE_CLI_CHANNEL: environmentChannel } : {}, } as unknown as AspireTerminalProvider; - const versions = [ - ['13.4.99', 'unsupported'], - ['13.5.0-preview.1.12345.6', 'unsupported'], - ['13.5.0', 'supported'], - ['13.5.1+abcdef', 'supported'], - ['14.0.0-preview.1', 'supported'], - ] as const; - let versionIndex = 0; - const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { - assert.strictEqual(command, '/exact/aspire'); + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { + assert.strictEqual(command, cliPath); assert.deepStrictEqual(args, ['--version']); - options?.stdoutCallback?.(`${versions[versionIndex++][0]}\n`); + options?.stdoutCallback?.('13.6.0-preview.1'); options?.exitCallback?.(0); return {} as ChildProcessWithoutNullStreams; }); const provider = new ConfigInfoProvider(terminalProvider); - for (const [version, expectedStatus] of versions) { + try { assert.deepStrictEqual( - await provider.getCliVersionStatus('13.5.0', { cliPath: '/exact/aspire' }), - { - cliPath: '/exact/aspire', - version, - status: expectedStatus, - }); + await provider.getCliIdentity({ cliPath }), + { cliPath, version: '13.6.0-preview.1', identityChannelOverride: 'daily' }); + + environmentChannel = ' stable '; + assert.deepStrictEqual( + await provider.getCliIdentity({ cliPath }), + { cliPath, version: '13.6.0-preview.1', identityChannelOverride: ' stable ' }); + assert.deepStrictEqual( + parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.6.0-preview.1', '13.6.0', undefined, 'stable'), + ' stable '), + { status: 'ineligible', currentVersion: '13.6.0-preview.1' }); + + environmentChannel = ' '; + assert.deepStrictEqual( + await provider.getCliIdentity({ cliPath }), + { cliPath, version: '13.6.0-preview.1', identityChannelOverride: ' ' }); + + environmentChannel = undefined; + assert.deepStrictEqual( + await provider.getCliIdentity({ cliPath }), + { cliPath, version: '13.6.0-preview.1', identityChannelOverride: 'staging' }); + + fs.writeFileSync(path.join(directory, '.aspire-install.json'), JSON.stringify({ + source: 'script', + channel: ' daily ', + })); + assert.deepStrictEqual( + await provider.getCliIdentity({ cliPath }), + { cliPath, version: '13.6.0-preview.1', identityChannelOverride: ' daily ' }); + + fs.writeFileSync(path.join(directory, '.aspire-install.json'), 'x'.repeat(64 * 1024 + 1)); + assert.deepStrictEqual( + await provider.getCliIdentity({ cliPath }), + { cliPath, version: '13.6.0-preview.1', identityChannelOverride: undefined }); + } + finally { + fs.rmSync(directory, { recursive: true, force: true }); } + }); - assert.strictEqual(spawnStub.callCount, versions.length); - for (const call of spawnStub.getCalls()) { - assert.strictEqual(call.args[3]?.createProcessGroup, true); - assert.strictEqual(call.args[3]?.noExtensionVariables, true); + test('getCliIdentity reads the sidecar beside a symlink target', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-symlink-')); + const targetDirectory = path.join(root, 'target'); + const linkedDirectory = path.join(root, 'link'); + fs.mkdirSync(targetDirectory); + fs.writeFileSync(path.join(targetDirectory, '.aspire-install.json'), JSON.stringify({ + source: 'script', + channel: 'daily', + })); + const executableName = process.platform === 'win32' ? 'aspire.exe' : 'aspire'; + fs.writeFileSync(path.join(targetDirectory, executableName), ''); + fs.symlinkSync(targetDirectory, linkedDirectory, process.platform === 'win32' ? 'junction' : 'dir'); + const cliPath = path.join(linkedDirectory, executableName); + const terminalProvider = { + getAspireCliExecutablePath: async () => cliPath, + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { + assert.strictEqual(command, cliPath); + assert.deepStrictEqual(args, ['--version']); + options?.stdoutCallback?.('13.6.0-preview.1'); + options?.exitCallback?.(0); + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + try { + assert.deepStrictEqual( + await provider.getCliIdentity({ cliPath }), + { cliPath, version: '13.6.0-preview.1', identityChannelOverride: 'daily' }); } + finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test('getCliUpdateRecommendation accepts structured doctor output on a nonzero exit', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { + assert.strictEqual(command, '/exact/aspire'); + assert.deepStrictEqual(args, ['doctor', '--format', 'json', '--nologo']); + assert.deepStrictEqual(options?.env, [{ name: 'ASPIRE_NON_INTERACTIVE', value: 'true' }]); + options?.stdoutCallback?.(createDoctorVersionOutput('13.5.0', '13.6.0')); + // `aspire doctor` exits nonzero when an unrelated prerequisite check fails, but its + // structured CLI update metadata is still valid. + options?.exitCallback?.(1); + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + assert.deepStrictEqual( + await provider.getCliUpdateRecommendation({ cliPath: '/exact/aspire' }), + { status: 'available', currentVersion: '13.5.0', version: '13.6.0' }); + assert.strictEqual(spawnStub.callCount, 1); }); - test('getCliVersionStatus keeps failed and malformed probes silent', async () => { + test('getCliUpdateRecommendation retries without nologo and keeps unavailable checks silent', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => '/unused/aspire', createEnvironment: () => ({}), } as unknown as AspireTerminalProvider; let attempt = 0; - sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { if (attempt++ === 0) { + options?.stderrCallback?.("Unrecognized command or argument '--nologo'."); options?.exitCallback?.(1); } else { - options?.stdoutCallback?.('Aspire CLI 13.4.0'); + options?.stdoutCallback?.('not json'); options?.exitCallback?.(0); } return {} as ChildProcessWithoutNullStreams; @@ -324,11 +524,75 @@ suite('configInfoProvider tests', () => { const showErrorMessage = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); const provider = new ConfigInfoProvider(terminalProvider); - assert.strictEqual(await provider.getCliVersionStatus('13.5.0', { cliPath: '/exact/aspire' }), null); - assert.strictEqual(await provider.getCliVersionStatus('13.5.0', { cliPath: '/exact/aspire' }), null); + assert.deepStrictEqual( + await provider.getCliUpdateRecommendation({ cliPath: '/exact/aspire' }), + { status: 'unavailable' }); + assert.deepStrictEqual(spawnStub.getCalls().map(call => call.args[2]), [ + ['doctor', '--format', 'json', '--nologo'], + ['doctor', '--format', 'json'], + ]); assert.strictEqual(showErrorMessage.callCount, 0); }); + test('getCliUpdateRecommendation cancels and terminates an in-flight doctor probe', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const childProcess = { kill: () => true } as unknown as ChildProcessWithoutNullStreams; + sinon.stub(cliModule, 'spawnCliProcess').returns(childProcess); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); + const cancellation = new vscode.CancellationTokenSource(); + const provider = new ConfigInfoProvider(terminalProvider); + + const probe = provider.getCliUpdateRecommendation({ + cliPath: '/exact/aspire', + cancellationToken: cancellation.token, + }); + cancellation.cancel(); + + assert.deepStrictEqual(await probe, { status: 'unavailable' }); + sinon.assert.calledOnceWithExactly(terminateStub, childProcess, 'cancelled Aspire CLI update probe'); + }); + + test('version and update probes do not settle before cancellation termination completes', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const childProcess = { kill: () => true } as unknown as ChildProcessWithoutNullStreams; + sinon.stub(cliModule, 'spawnCliProcess').returns(childProcess); + const terminations: Array<() => void> = []; + sinon.stub(cliModule, 'terminateCliProcess').callsFake(() => + new Promise(resolve => terminations.push(resolve))); + const provider = new ConfigInfoProvider(terminalProvider); + + for (const startProbe of [ + (cancellation: vscode.CancellationTokenSource) => provider.getCliVersion({ + cliPath: '/exact/aspire', + cancellationToken: cancellation.token, + }), + (cancellation: vscode.CancellationTokenSource) => provider.getCliUpdateRecommendation({ + cliPath: '/exact/aspire', + cancellationToken: cancellation.token, + }), + ]) { + const cancellation = new vscode.CancellationTokenSource(); + const probe = startProbe(cancellation); + let settled = false; + void probe.then(() => settled = true); + + cancellation.cancel(); + await Promise.resolve(); + assert.strictEqual(settled, false); + + terminations.shift()?.(); + await probe; + assert.strictEqual(settled, true); + cancellation.dispose(); + } + }); + test('getCapabilityStatus uses advertised capabilities before the minimum-version fallback', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => '/unused/aspire', diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index b91a3e0e92c..aa08a85de0e 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -1,19 +1,50 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import * as strings from '../loc/strings'; -import { CliVersionStatus, CliVersionStatusOptions } from '../utils/configInfoProvider'; +import { + CliIdentityInfo, + CliUpdateRecommendation, + CliUpdateRecommendationOptions, + CliVersionInfo, + CliVersionStatusOptions, +} from '../utils/configInfoProvider'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; -import { minimumSupportedAspireCliVersion, OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier'; +import { OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier'; suite('outdatedCliNotifier', () => { class FakeVersionProvider { - result: CliVersionStatus | null = null; - resultPromise: Promise | undefined; - readonly calls: Array<{ minimumVersion: string; options?: CliVersionStatusOptions }> = []; + identity: CliIdentityInfo | null = { + cliPath: '/cli/aspire', + version: '13.5.0', + }; + identityPromise: Promise | undefined; + currentVersion: CliVersionInfo | null = { + cliPath: '/cli/aspire', + version: '13.5.0', + }; + recommendation: CliUpdateRecommendation = { + status: 'available', + currentVersion: '13.5.0', + version: '13.6.0', + }; + recommendationPromise: Promise | undefined; + readonly identityCalls: Array = []; + readonly versionCalls: Array = []; + readonly recommendationCalls: Array = []; + + async getCliIdentity(options?: CliVersionStatusOptions): Promise { + this.identityCalls.push(options); + return await (this.identityPromise ?? this.identity); + } - async getCliVersionStatus(minimumVersion: string, options?: CliVersionStatusOptions): Promise { - this.calls.push({ minimumVersion, options }); - return await (this.resultPromise ?? this.result); + async getCliVersion(options?: CliVersionStatusOptions): Promise { + this.versionCalls.push(options); + return this.currentVersion; + } + + async getCliUpdateRecommendation(options?: CliUpdateRecommendationOptions): Promise { + this.recommendationCalls.push(options); + return await (this.recommendationPromise ?? this.recommendation); } } @@ -34,7 +65,7 @@ suite('outdatedCliNotifier', () => { } } - function createNotifier(): { + function createNotifier(now: () => number = Date.now): { notifier: OutdatedCliNotifier; versionProvider: FakeVersionProvider; surface: FakeSurface; @@ -42,253 +73,292 @@ suite('outdatedCliNotifier', () => { const versionProvider = new FakeVersionProvider(); const surface = new FakeSurface(); return { - notifier: new OutdatedCliNotifier(versionProvider, surface), + notifier: new OutdatedCliNotifier(versionProvider, surface, now), versionProvider, surface, }; } - test('does not warn for supported or unavailable CLI versions', async () => { + async function waitFor(predicate: () => boolean, message: string): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + if (predicate()) { + return; + } + await new Promise(resolve => setImmediate(resolve)); + } + assert.fail(message); + } + + test('warns once and forwards the exact target and path', async () => { const { notifier, versionProvider, surface } = createNotifier(); - versionProvider.result = { - cliPath: '/cli/aspire', + const target = workspaceFolderCliPathTarget({ + uri: vscode.Uri.file('/workspace/a'), + name: 'a', + index: 0, + }); + versionProvider.identity = { + cliPath: '/workspace/a/.aspire/bin/aspire', version: '13.5.0', - status: 'supported', }; + versionProvider.currentVersion = versionProvider.identity; + surface.selection = strings.updateAspireCliAction; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - versionProvider.result = null; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await notifier.notifyIfOutdated(target, '/workspace/a/.aspire/bin/aspire'); + await notifier.notifyIfOutdated(target, '/workspace/a/.aspire/bin/aspire'); - assert.deepStrictEqual(surface.warnings, []); - assert.deepStrictEqual( - versionProvider.calls.map(call => call.minimumVersion), - [minimumSupportedAspireCliVersion, minimumSupportedAspireCliVersion]); + assert.strictEqual(surface.warnings.length, 1); + assert.strictEqual( + surface.warnings[0].message, + 'Aspire CLI 13.5.0 at /workspace/a/.aspire/bin/aspire has a newer version available for its current channel: 13.6.0.'); + assert.deepStrictEqual(surface.warnings[0].actions, [strings.updateAspireCliAction]); + assert.deepStrictEqual(surface.commands, [{ + command: 'aspire-vscode.updateSelf', + args: [target, '/workspace/a/.aspire/bin/aspire'], + }]); notifier.dispose(); }); - test('warns only once for the same resolved executable and version', async () => { - const { notifier, versionProvider, surface } = createNotifier(); - versionProvider.result = { - cliPath: '/cli/aspire', - version: '13.4.9', - status: 'unsupported', + test('uses five-minute version and six-hour update refresh intervals', async () => { + let now = 0; + const { notifier, versionProvider, surface } = createNotifier(() => now); + versionProvider.recommendation = { + status: 'none', + currentVersion: '13.5.0', }; await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + now = 5 * 60 * 1_000 - 1; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + assert.strictEqual(versionProvider.identityCalls.length, 1); + assert.strictEqual(versionProvider.recommendationCalls.length, 1); + + now++; await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + assert.strictEqual(versionProvider.identityCalls.length, 2); + assert.strictEqual(versionProvider.recommendationCalls.length, 1); + + now = 6 * 60 * 60 * 1_000; + versionProvider.recommendation = { + status: 'available', + currentVersion: '13.5.0', + version: '13.7.0', + }; await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + assert.strictEqual(versionProvider.identityCalls.length, 3); + assert.strictEqual(versionProvider.recommendationCalls.length, 2); assert.strictEqual(surface.warnings.length, 1); - assert.strictEqual( - surface.warnings[0].message, - "Aspire CLI 13.4.9 at /cli/aspire is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later to avoid a known startup failure in VS Code."); notifier.dispose(); }); - test('warns for distinct CLI paths and replacements at the same path', async () => { - const { notifier, versionProvider, surface } = createNotifier(); - const outdatedVersions = [ - { cliPath: '/cli/first/aspire', version: '13.4.8' }, - { cliPath: '/cli/second/aspire', version: '13.4.8' }, - { cliPath: '/cli/first/aspire', version: '13.4.9' }, - ]; - - for (const version of outdatedVersions) { - versionProvider.result = { ...version, status: 'unsupported' }; - await notifier.notifyIfOutdated(windowCliPathTarget, version.cliPath); + test('samples version independently while unavailable doctor checks back off', async () => { + let now = 0; + const { notifier, versionProvider } = createNotifier(() => now); + versionProvider.recommendation = { status: 'unavailable' }; + + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + for (const minute of [5, 10, 15, 20]) { + now = minute * 60 * 1_000; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); } - assert.strictEqual(surface.warnings.length, outdatedVersions.length); - assert.deepStrictEqual( - surface.warnings.map(warning => warning.message), - outdatedVersions.map(version => - `Aspire CLI ${version.version} at ${version.cliPath} is older than 13.5.0. Update the CLI and the AppHost's Aspire packages to 13.5.0 or later to avoid a known startup failure in VS Code.`)); - assert.notStrictEqual(surface.warnings[0].message, surface.warnings[1].message); + assert.strictEqual(versionProvider.identityCalls.length, 5); + // Retries at 0, 5, 10 and 15 minutes establish 1/2/4/8-minute backoff. The 20-minute + // version sample remains independent but does not run doctor before minute 23. + assert.strictEqual(versionProvider.recommendationCalls.length, 4); notifier.dispose(); }); - test('coalesces shared multi-root paths and bounds distinct version probes', async () => { - let activeProbeCount = 0; - let maximumActiveProbeCount = 0; - const probedPaths: string[] = []; - const releaseProbes: Array<() => void> = []; - const versionProvider = { - getCliVersionStatus: async ( - _minimumVersion: string, - options?: CliVersionStatusOptions, - ): Promise => { - const cliPath = options?.cliPath; - assert.ok(cliPath); - probedPaths.push(cliPath); - activeProbeCount++; - maximumActiveProbeCount = Math.max(maximumActiveProbeCount, activeProbeCount); - return await new Promise(resolve => { - releaseProbes.push(() => { - activeProbeCount--; - resolve({ - cliPath, - version: '13.5.0', - status: 'supported', - }); - }); - }); - }, + test('changed version or channel override resets update state', async () => { + let now = 0; + const { notifier, versionProvider } = createNotifier(() => now); + versionProvider.recommendation = { + status: 'none', + currentVersion: '13.5.0', }; - const surface = new FakeSurface(); - const notifier = new OutdatedCliNotifier(versionProvider, surface); - const requests = Array.from({ length: 40 }, (_, index) => { - const target = workspaceFolderCliPathTarget({ - uri: vscode.Uri.file(`/workspace/${index}`), - name: `folder-${index}`, - index, - }); - const cliPath = index < 20 ? '/shared/aspire' : `/cli/${index}/aspire`; - return notifier.notifyIfOutdated(target, cliPath); - }); - const expectedProbeCount = 21; - let releasedProbeCount = 0; - - while (releasedProbeCount < expectedProbeCount) { - await new Promise(resolve => setImmediate(resolve)); - const batch = releaseProbes.splice(0); - assert.ok(batch.length > 0, 'Expected a bounded batch of version probes to be ready.'); - releasedProbeCount += batch.length; - batch.forEach(release => release()); - } - await Promise.all(requests); + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - assert.strictEqual(probedPaths.filter(cliPath => cliPath === '/shared/aspire').length, 1); - assert.strictEqual(probedPaths.length, expectedProbeCount); - assert.strictEqual(maximumActiveProbeCount, 4); - assert.deepStrictEqual(surface.warnings, []); - notifier.dispose(); - }); + now = 5 * 60 * 1_000; + versionProvider.identity = { + cliPath: '/cli/aspire', + version: '13.5.1', + }; + versionProvider.recommendation = { + status: 'none', + currentVersion: '13.5.1', + }; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - test('transfers a released probe slot without exposing it to a new arrival', async () => { - const notifier = new OutdatedCliNotifier(new FakeVersionProvider(), new FakeSurface()); - const slots = notifier as unknown as { - _activeProbeCount: number; - _acquireProbeSlot(): Promise; - _releaseProbeSlot(): void; + now = 10 * 60 * 1_000; + versionProvider.identity = { + ...versionProvider.identity, + identityChannelOverride: 'daily', + }; + versionProvider.recommendation = { + status: 'none', + currentVersion: '13.5.1', }; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - assert.deepStrictEqual( - await Promise.all(Array.from({ length: 4 }, () => slots._acquireProbeSlot())), - [true, true, true, true]); - const firstWaiter = slots._acquireProbeSlot(); - - slots._releaseProbeSlot(); - const newArrival = slots._acquireProbeSlot(); - let newArrivalResolved = false; - void newArrival.then(() => newArrivalResolved = true); - assert.strictEqual(await firstWaiter, true); - await Promise.resolve(); - - assert.strictEqual(slots._activeProbeCount, 4); - assert.strictEqual(newArrivalResolved, false); - - slots._releaseProbeSlot(); - assert.strictEqual(await newArrival, true); - for (let i = 0; i < 4; i++) { - slots._releaseProbeSlot(); - } - assert.strictEqual(slots._activeProbeCount, 0); + assert.strictEqual(versionProvider.recommendationCalls.length, 3); + assert.strictEqual(versionProvider.recommendationCalls[2]?.identityChannelOverride, 'daily'); notifier.dispose(); }); - test('reprobes the same active CLI path and observes an in-place replacement', async () => { - const results: CliVersionStatus[] = [{ - cliPath: '/cli/aspire', - version: '13.5.0', - status: 'supported', - }, { + test('ineligible CLI never reruns doctor for the same identity', async () => { + let now = 0; + const { notifier, versionProvider, surface } = createNotifier(() => now); + versionProvider.recommendation = { + status: 'ineligible', + currentVersion: '13.6.0-dev', + }; + versionProvider.identity = { cliPath: '/cli/aspire', - version: '13.4.9', - status: 'unsupported', - }]; - let probeIndex = 0; - const versionProvider = { - getCliVersionStatus: async (): Promise => results[probeIndex++], + version: '13.6.0-dev', }; - const surface = new FakeSurface(); - const notifier = new OutdatedCliNotifier(versionProvider, surface); await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + now = 5 * 60 * 1_000; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + now = 24 * 60 * 60 * 1_000; await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - assert.strictEqual(probeIndex, 2); - assert.strictEqual(surface.warnings.length, 1); - assert.ok(surface.warnings[0].message.includes('Aspire CLI 13.4.9 at /cli/aspire is older than 13.5.0')); + assert.strictEqual(versionProvider.identityCalls.length, 3); + assert.strictEqual(versionProvider.recommendationCalls.length, 1); + assert.deepStrictEqual(surface.warnings, []); notifier.dispose(); }); - test('updates the exact workspace CLI that triggered the warning', async () => { + test('coalesces same-path checks and does not queue behind the active doctor', async () => { + let now = 0; + const versionProvider = new FakeVersionProvider(); + versionProvider.getCliIdentity = async options => { + versionProvider.identityCalls.push(options); + return { + cliPath: options?.cliPath ?? '/cli/aspire', + version: '13.5.0', + }; + }; + let activeDoctors = 0; + let maximumActiveDoctors = 0; + const releaseDoctors: Array<() => void> = []; + versionProvider.getCliUpdateRecommendation = async options => { + versionProvider.recommendationCalls.push(options); + activeDoctors++; + maximumActiveDoctors = Math.max(maximumActiveDoctors, activeDoctors); + return await new Promise(resolve => { + releaseDoctors.push(() => { + activeDoctors--; + resolve({ + status: 'none', + currentVersion: '13.5.0', + }); + }); + }); + }; + const notifier = new OutdatedCliNotifier(versionProvider, new FakeSurface(), () => now); + + const shared = Array.from({ length: 10 }, () => + notifier.notifyIfOutdated(windowCliPathTarget, '/shared/aspire')); + const distinct = notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire'); + await waitFor(() => releaseDoctors.length === 1, 'Expected first serialized doctor.'); + releaseDoctors.shift()?.(); + await Promise.all([...shared, distinct]); + + assert.strictEqual(versionProvider.identityCalls.length, 2); + assert.strictEqual(versionProvider.recommendationCalls.length, 1); + assert.strictEqual(maximumActiveDoctors, 1); + + const checkedPath = versionProvider.recommendationCalls[0]?.cliPath; + const skippedPath = checkedPath === '/shared/aspire' ? '/other/aspire' : '/shared/aspire'; + now = 5 * 60 * 1_000; + const retry = notifier.notifyIfOutdated(windowCliPathTarget, skippedPath); + await waitFor(() => releaseDoctors.length === 1, 'Expected skipped path to retry doctor.'); + releaseDoctors.shift()?.(); + await retry; + + assert.strictEqual(versionProvider.recommendationCalls.length, 2); + assert.strictEqual(maximumActiveDoctors, 1); + notifier.dispose(); + }); + + test('does not warn when physical and effective versions disagree', async () => { const { notifier, versionProvider, surface } = createNotifier(); - const target = workspaceFolderCliPathTarget({ - uri: vscode.Uri.file('/workspace/a'), - name: 'a', - index: 0, - }); - versionProvider.result = { - cliPath: '/workspace/a/.aspire/bin/aspire', - version: '13.4.9', - status: 'unsupported', + versionProvider.identity = { + cliPath: '/cli/aspire', + version: '13.7.0-preview.1', + identityChannelOverride: 'daily', + }; + versionProvider.recommendation = { + status: 'available', + currentVersion: '13.6.0-preview.1', + version: '13.7.0-preview.2', }; - surface.selection = strings.updateAspireCliAction; - await notifier.notifyIfOutdated(target, '/workspace/a/.aspire/bin/aspire'); + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - assert.deepStrictEqual(surface.warnings[0].actions, [strings.updateAspireCliAction]); - assert.deepStrictEqual(surface.commands, [{ - command: 'aspire-vscode.updateSelf', - args: [target, '/workspace/a/.aspire/bin/aspire'], - }]); + assert.deepStrictEqual(surface.warnings, []); notifier.dispose(); }); - test('dispose cancels an in-flight version probe and suppresses its warning', async () => { + test('stale warning action is suppressed but inconclusive recheck remains usable', async () => { const { notifier, versionProvider, surface } = createNotifier(); - let resolveProbe!: (result: CliVersionStatus | null) => void; - versionProvider.resultPromise = new Promise(resolve => resolveProbe = resolve); + let resolveSelection!: (selection: string | undefined) => void; + surface.selectionPromise = new Promise(resolve => resolveSelection = resolve); const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - await new Promise(resolve => setImmediate(resolve)); - const cancellationToken = versionProvider.calls[0].options?.cancellationToken; - assert.ok(cancellationToken); - assert.strictEqual(cancellationToken.isCancellationRequested, false); - - notifier.dispose(); - assert.strictEqual(cancellationToken.isCancellationRequested, true); - resolveProbe({ + await waitFor(() => surface.warnings.length === 1, 'Expected warning to open.'); + versionProvider.currentVersion = { cliPath: '/cli/aspire', - version: '13.4.9', - status: 'unsupported', - }); + version: '13.5.1', + }; + resolveSelection(strings.updateAspireCliAction); await notification; + assert.deepStrictEqual(surface.commands, []); + + const second = createNotifier(); + second.surface.selection = strings.updateAspireCliAction; + second.versionProvider.currentVersion = null; + await second.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + assert.strictEqual(second.surface.commands.length, 1); + second.notifier.dispose(); + notifier.dispose(); + }); + test('dispose cancels queued and active probes and suppresses warning actions', async () => { + const { notifier, versionProvider, surface } = createNotifier(); + let resolveIdentity!: (identity: CliIdentityInfo | null) => void; + versionProvider.identityPromise = new Promise(resolve => resolveIdentity = resolve); + + const active = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await waitFor(() => versionProvider.identityCalls.length === 1, 'Expected identity probe to start.'); + const cancellationToken = versionProvider.identityCalls[0]?.cancellationToken; + notifier.dispose(); + resolveIdentity(versionProvider.identity); + await active; + + assert.strictEqual(cancellationToken?.isCancellationRequested, true); assert.deepStrictEqual(surface.warnings, []); assert.deepStrictEqual(surface.commands, []); }); - test('dispose while the warning awaits selection suppresses the update action', async () => { - const { notifier, versionProvider, surface } = createNotifier(); - versionProvider.result = { - cliPath: '/cli/aspire', - version: '13.4.9', - status: 'unsupported', + test('dispose prevents queued version work from starting', async () => { + const versionProvider = new FakeVersionProvider(); + const resolveIdentities: Array<(identity: CliIdentityInfo | null) => void> = []; + versionProvider.getCliIdentity = async options => { + versionProvider.identityCalls.push(options); + return await new Promise(resolve => resolveIdentities.push(resolve)); }; - let resolveSelection!: (selection: string | undefined) => void; - surface.selectionPromise = new Promise(resolve => resolveSelection = resolve); - - const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - await new Promise(resolve => setImmediate(resolve)); - assert.strictEqual(surface.warnings.length, 1); + const notifier = new OutdatedCliNotifier(versionProvider, new FakeSurface()); + const notifications = Array.from({ length: 5 }, (_, index) => + notifier.notifyIfOutdated(windowCliPathTarget, `/cli/${index}/aspire`)); + await waitFor(() => versionProvider.identityCalls.length === 4, 'Expected the version pool to fill.'); notifier.dispose(); - resolveSelection(strings.updateAspireCliAction); - await notification; + resolveIdentities.forEach(resolve => resolve(null)); + await Promise.all(notifications); - assert.deepStrictEqual(surface.commands, []); + assert.strictEqual(versionProvider.identityCalls.length, 4); + assert.deepStrictEqual(versionProvider.recommendationCalls, []); }); }); diff --git a/extension/src/test/registerCliCommands.test.ts b/extension/src/test/registerCliCommands.test.ts index 7b48ed4da0e..19eb7d0e6ad 100644 --- a/extension/src/test/registerCliCommands.test.ts +++ b/extension/src/test/registerCliCommands.test.ts @@ -391,11 +391,22 @@ suite('registerCliCommands', () => { test('update self remains window scoped without a CLI availability gate', async () => { workspaceFoldersStub.value([createWorkspaceFolder('a', '/repo/a')]); + const resolutions: unknown[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); - await callbacks.get('aspire-vscode.updateSelf')!(); + try { + await callbacks.get('aspire-vscode.updateSelf')!(); + } + finally { + subscription.dispose(); + } assert.strictEqual(resolveCliPathStub.called, false); - assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { target: windowCliPathTarget })); + assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { + target: windowCliPathTarget, + reportCliResolution: false, + })); + assert.deepStrictEqual(resolutions, []); }); test('update self uses the pre-resolved workspace CLI supplied by the warning action', async () => { @@ -408,6 +419,7 @@ suite('registerCliCommands', () => { assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { target, cliPath: '/repo/a/.aspire/bin/aspire', + reportCliResolution: false, })); }); }); \ No newline at end of file diff --git a/extension/src/test/resourceCommandArguments.test.ts b/extension/src/test/resourceCommandArguments.test.ts index d6d758c59b6..96398d7cda9 100644 --- a/extension/src/test/resourceCommandArguments.test.ts +++ b/extension/src/test/resourceCommandArguments.test.ts @@ -16,6 +16,7 @@ import { createResourceCommandArgumentLoader } from '../views/ResourceCommandArg import { ResourceCommandArgumentInputJson } from '../data/AppHostDataRepository'; import { extensionLogOutputChannel } from '../utils/logging'; import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; function makeInput(overrides: Partial = {}): ResourceCommandArgumentInputJson { return { @@ -457,6 +458,8 @@ suite('ResourceCommandArguments', () => { return { stdin: { end: () => { } } } as any; }); + const resolutions: string[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); try { const loader = createResourceCommandArgumentLoader({ @@ -469,8 +472,10 @@ suite('ResourceCommandArguments', () => { await loader([]); assert.ok(getAspireCliExecutablePathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); + assert.deepStrictEqual(resolutions, ['/repo/bin/aspire']); } finally { + subscription.dispose(); spawnStub.restore(); withProgressStub.restore(); getWorkspaceFolderStub.restore(); diff --git a/extension/src/utils/AspirePackageRestoreProvider.ts b/extension/src/utils/AspirePackageRestoreProvider.ts index 6ce30e3e9f7..bebb07c27b6 100644 --- a/extension/src/utils/AspirePackageRestoreProvider.ts +++ b/extension/src/utils/AspirePackageRestoreProvider.ts @@ -6,6 +6,7 @@ import { ChildProcessWithoutNullStreams } from 'child_process'; import { spawnCliProcess } from './process/cliProcess'; import { AspireTerminalProvider } from './AspireTerminalProvider'; import { getCliPathTargetForUri } from './cliPathVariables'; +import { reportCliResolvedForOperation } from './cliOperationResolution'; import { extensionLogOutputChannel } from './logging'; import { getEnableAutoRestore } from './settings'; import { runningAspireRestore, runningAspireRestoreProgress, aspireRestoreCompleted, aspireRestoreAllCompleted, aspireRestoreFailed, aspireRestoreFailedStatusBar } from '../loc/strings'; @@ -195,10 +196,12 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { this._showProgress(); try { - const cliPath = await this._terminalProvider.getAspireCliExecutablePath(getCliPathTargetForUri(uri)); + const target = getCliPathTargetForUri(uri); + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(target); if (this._disposed) { return; } + reportCliResolvedForOperation(target, cliPath); await new Promise((resolve, reject) => { let settled = false; const proc = spawnCliProcess(this._terminalProvider, cliPath, ['restore'], { diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts index 999fb70724f..dd510f12db3 100644 --- a/extension/src/utils/AspireTerminalProvider.ts +++ b/extension/src/utils/AspireTerminalProvider.ts @@ -11,6 +11,7 @@ import { ASPIRE_CLI_PATH_ENV_VAR, getForwardableAspireCliPath, getForwardableRes import { CliPathResolutionTarget, getCliPathTargetKey, windowCliPathTarget } from './cliPathVariables'; import path from 'path'; import { assertNoTerminalControlCharacters } from './cmdShim'; +import { reportCliResolvedForOperation } from './cliOperationResolution'; // Re-exported so existing importers keep a single implementation of the guard. export { assertNoTerminalControlCharacters }; @@ -32,6 +33,7 @@ export interface SendAspireCommandOptions { terminalTarget?: 'shared' | 'editor'; target: CliPathResolutionTarget; cliPath?: string; + reportCliResolution?: boolean; } // String parts are fixed CLI syntax and are validated before interpolation. @@ -168,7 +170,11 @@ export class AspireTerminalProvider implements vscode.Disposable { async sendAspireCommandToAspireTerminal(subcommand: AspireSubcommand, showTerminal: boolean = true, additionalArgs?: string[], options?: SendAspireCommandOptions) { const target = options?.target ?? windowCliPathTarget; + const resolvedForThisCommand = options?.cliPath === undefined; const cliPath = options?.cliPath ?? await this.getAspireCliExecutablePath(target); + if (resolvedForThisCommand && options?.reportCliResolution !== false) { + reportCliResolvedForOperation(target, cliPath); + } const subcommandLine = formatSubcommand(subcommand); assertNoTerminalControlCharacters(cliPath); diff --git a/extension/src/utils/appHostDiscovery.ts b/extension/src/utils/appHostDiscovery.ts index 661f74e0794..bfe895c1688 100644 --- a/extension/src/utils/appHostDiscovery.ts +++ b/extension/src/utils/appHostDiscovery.ts @@ -20,7 +20,6 @@ import { createLsStreamCandidateHandler, parseCandidateOutput, parseLegacyGetApp import { discoverProjectAppHostsFromWorkspaceFiles, findConfiguredAppHostPaths, formatErrorMessage } from './appHostProjectFiles'; import { findCandidateForEditorFile, findWorkspaceDefaultCandidate, getDebugTargetForCandidate, sortCandidatesByPath } from './appHostCandidateSelection'; import type { CandidateAppHostDisplayInfo, IncrementalCandidateCallback } from './appHostCandidateTypes'; -import { reportCliResolvedForOperation } from './cliOperationResolution'; export { isSamePath }; export { isSameFileSystemEntry }; @@ -48,10 +47,14 @@ interface CachedAppHostDiscovery { completed: boolean; started: boolean; stale: boolean; - reportCliResolution: boolean; resolvedCliPath?: string; } +export interface AppHostDiscoveryCliResolution { + workspaceFolder: vscode.WorkspaceFolder; + cliPath: string; +} + interface CliProcessResult { stdout: string; stderr: string; @@ -63,6 +66,7 @@ export class AppHostDiscoveryService implements vscode.Disposable { private static readonly _streamingDiscoveryMaxRuntimeMs = 5 * 60 * 1000; private readonly _onDidChangeCandidates = new vscode.EventEmitter(); + private readonly _onDidResolveCli = new vscode.EventEmitter(); private readonly _cache = new Map(); private readonly _activeDiscoveries = new Set(); private readonly _watchers = new Map(); @@ -72,6 +76,7 @@ export class AppHostDiscoveryService implements vscode.Disposable { private readonly _configInfoProvider: ConfigInfoProvider; private _disposed = false; readonly onDidChangeCandidates = this._onDidChangeCandidates.event; + readonly onDidResolveCli = this._onDidResolveCli.event; constructor(private readonly _terminalProvider: AspireTerminalProvider, configInfoProvider?: ConfigInfoProvider) { this._configInfoProvider = configInfoProvider ?? new ConfigInfoProvider(_terminalProvider); @@ -82,7 +87,6 @@ export class AppHostDiscoveryService implements vscode.Disposable { forceRefresh = false, cancellationToken?: vscode.CancellationToken, onIncrementalCandidate: IncrementalCandidateCallback = () => { }, - reportCliResolution = false, ): Promise { this._throwIfDisposed(); throwIfCancellationRequested(cancellationToken); @@ -105,13 +109,6 @@ export class AppHostDiscoveryService implements vscode.Disposable { if (!cachedDiscovery) { cachedDiscovery = this._createCachedDiscovery(workspaceFolder, key, forceRefresh); } - if (reportCliResolution) { - cachedDiscovery.reportCliResolution = true; - if (cachedDiscovery.resolvedCliPath) { - reportCliResolvedForOperation(workspaceFolderCliPathTarget(workspaceFolder), cachedDiscovery.resolvedCliPath); - } - } - const candidateProgressCallback = (candidate: CandidateAppHostDisplayInfo): void => { if (!cancellationToken?.isCancellationRequested) { notifyCandidateProgressCallback(onIncrementalCandidate, candidate); @@ -134,16 +131,8 @@ export class AppHostDiscoveryService implements vscode.Disposable { } } - reportResolvedCliForWorkspace(workspaceFolder: vscode.WorkspaceFolder): void { - const cachedDiscovery = this._cache.get(path.resolve(workspaceFolder.uri.fsPath)); - if (!cachedDiscovery) { - return; - } - - cachedDiscovery.reportCliResolution = true; - if (cachedDiscovery.resolvedCliPath) { - reportCliResolvedForOperation(workspaceFolderCliPathTarget(workspaceFolder), cachedDiscovery.resolvedCliPath); - } + getResolvedCliPath(workspaceFolder: vscode.WorkspaceFolder): string | undefined { + return this._cache.get(path.resolve(workspaceFolder.uri.fsPath))?.resolvedCliPath; } private _createCachedDiscovery( @@ -162,7 +151,6 @@ export class AppHostDiscoveryService implements vscode.Disposable { completed: false, started: false, stale: false, - reportCliResolution: false, }; const reportCandidateProgress = (candidate: CandidateAppHostDisplayInfo) => { if (isExcludedDiscoveryCandidate(workspaceFolder, vscode.Uri.file(candidate.path))) { @@ -183,9 +171,9 @@ export class AppHostDiscoveryService implements vscode.Disposable { cancellationSource.token, forceRefresh, cliPath => { - cachedDiscovery.resolvedCliPath = cliPath; - if (cachedDiscovery.reportCliResolution && this._cache.get(key) === cachedDiscovery) { - reportCliResolvedForOperation(workspaceFolderCliPathTarget(workspaceFolder), cliPath); + if (this._cache.get(key) === cachedDiscovery) { + cachedDiscovery.resolvedCliPath = cliPath; + this._onDidResolveCli.fire({ workspaceFolder, cliPath }); } }) .then(async discovery => { @@ -311,6 +299,7 @@ export class AppHostDiscoveryService implements vscode.Disposable { this._cancelActiveCliProcesses.clear(); this._activeCliProcesses.clear(); this._onDidChangeCandidates.dispose(); + this._onDidResolveCli.dispose(); } private async _discoverCore( diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index 3c3e495e645..e317188105d 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -1,4 +1,6 @@ import * as vscode from 'vscode'; +import * as path from 'path'; +import { readFile, realpath, stat } from 'fs/promises'; import type { ChildProcessWithoutNullStreams } from 'child_process'; import { AspireTerminalProvider } from './AspireTerminalProvider'; import { spawnCliProcess, terminateCliProcess } from './process/cliProcess'; @@ -7,10 +9,14 @@ import { CapabilityStatus, ConfigInfo, FeatureInfo, PropertyInfo, SettingsSchema import * as strings from '../loc/strings'; import { isNoLogoUnsupportedOutput, noLogoOption, removeRootNoLogoOption } from './cliCompatibility'; import { CliPathResolutionTarget, windowCliPathTarget } from './cliPathVariables'; +import { nonInteractiveCliEnvironment } from './environment'; const configInfoTimeoutMs = 30_000; const cliVersionProbeTimeoutMs = 30_000; +const cliUpdateProbeTimeoutMs = 130_000; const maxCliVersionOutputLength = 128; +const maxCliUpdateOutputLength = 1024 * 1024; +const maxCliIdentitySidecarLength = 64 * 1024; const cliVersionPattern = /^(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,4})(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; type RawFeatureInfo = Partial & { @@ -66,9 +72,26 @@ export interface CliVersionStatusOptions { timeoutMs?: number; } -export interface CliVersionStatus { +export interface CliUpdateRecommendationOptions extends CliVersionStatusOptions { + identityChannelOverride?: string; +} + +export interface CliVersionInfo { cliPath: string; version: string; +} + +export interface CliIdentityInfo extends CliVersionInfo { + identityChannelOverride?: string; +} + +export type CliUpdateRecommendation = + | { status: 'available'; currentVersion: string; version: string } + | { status: 'none'; currentVersion: string } + | { status: 'ineligible'; currentVersion: string } + | { status: 'unavailable' }; + +interface CliVersionStatus extends CliVersionInfo { status: Exclude; } @@ -78,6 +101,7 @@ interface CliVersion { minor: number; patch: number; isPrerelease: boolean; + prereleaseIdentifiers: string[]; } /** @@ -204,18 +228,10 @@ export class ConfigInfoProvider { } /** - * Compares the exact resolved Aspire CLI against a minimum stable version. The bounded - * `aspire --version` probe is locale-independent and intentionally uncached so replacing an - * executable in place is observed on the next check. Probe failures return `null` without - * showing an error notification. + * Probes the exact resolved Aspire CLI version. The result is intentionally uncached so callers + * observe an executable replaced in place. */ - async getCliVersionStatus(minimumVersion: string, options?: CliVersionStatusOptions): Promise { - const parsedMinimumVersion = parseCliVersion(minimumVersion); - if (!parsedMinimumVersion) { - extensionLogOutputChannel.warn(`Unable to probe Aspire CLI version: invalid minimum version '${minimumVersion}'.`); - return null; - } - + async getCliVersion(options?: CliVersionStatusOptions): Promise { const target = options?.target ?? windowCliPathTarget; const startTime = Date.now(); const timeoutMs = Math.min(options?.timeoutMs ?? cliVersionProbeTimeoutMs, cliVersionProbeTimeoutMs); @@ -229,7 +245,47 @@ export class ConfigInfoProvider { return null; } - return await this._getCliVersionStatus(cliPath, parsedMinimumVersion, remainingTimeoutMs, options?.cancellationToken); + const version = await this._probeCliVersion(cliPath, remainingTimeoutMs, options?.cancellationToken); + return version ? { cliPath, version: version.value } : null; + } + + /** + * Probes the effective CLI version and samples any channel override that the CLI resolves ahead + * of its assembly identity: inherited environment first, then the install sidecar. + */ + async getCliIdentity(options?: CliVersionStatusOptions): Promise { + const cli = await this.getCliVersion(options); + if (!cli || options?.cancellationToken?.isCancellationRequested) { + return null; + } + + return { + ...cli, + identityChannelOverride: await this._getIdentityChannelOverride(cli.cliPath), + }; + } + + /** + * Returns a same-lane update recommended by the resolved CLI's structured update check. Stable + * installations follow stable releases and prerelease installations follow prerelease + * recommendations. Failures, cross-lane recommendations, and no-update results are silent. + */ + async getCliUpdateRecommendation(options?: CliUpdateRecommendationOptions): Promise { + if (options?.cancellationToken?.isCancellationRequested) { + return { status: 'unavailable' }; + } + + const target = options?.target ?? windowCliPathTarget; + const cliPath = options?.cliPath ?? await this._resolveCliPath(true, target, options?.cancellationToken); + if (!cliPath || options?.cancellationToken?.isCancellationRequested) { + return { status: 'unavailable' }; + } + + return await this._fetchCliUpdateRecommendation( + cliPath, + target, + options?.identityChannelOverride, + options?.cancellationToken); } /** @@ -280,20 +336,73 @@ export class ConfigInfoProvider { ?? 'unavailable'; } - private _getCliVersionStatus( + private async _getIdentityChannelOverride(cliPath: string): Promise { + const environment = this._terminalProvider.createEnvironment(undefined, undefined, true, cliPath) as Record; + const environmentChannel = getEnvironmentValue(environment, 'ASPIRE_CLI_CHANNEL'); + if (environmentChannel) { + return environmentChannel.toLowerCase(); + } + + try { + // Install routes write: + // /.aspire-install.json + // { "source": "script", "channel": "stable", ... } + // Identity resolution uses this channel after the environment and before assembly + // metadata. Unknown or malformed sidecars fall back to the physical doctor identity. + let physicalCliPath = cliPath; + try { + physicalCliPath = await realpath(cliPath); + } + catch { + // Keep the invocation path when it cannot be canonicalized. The CLI itself uses + // the same fallback when resolving its process path. + } + const sidecarPath = path.join(path.dirname(physicalCliPath), '.aspire-install.json'); + if ((await stat(sidecarPath)).size > maxCliIdentitySidecarLength) { + return undefined; + } + const sidecar = JSON.parse(await readFile(sidecarPath, 'utf8')) as { channel?: unknown }; + return typeof sidecar.channel === 'string' && sidecar.channel.length > 0 + ? sidecar.channel.toLowerCase() + : undefined; + } + catch { + return undefined; + } + } + + private async _getCliVersionStatus( cliPath: string, minimumVersion: CliVersion, timeoutMs: number, cancellationToken?: vscode.CancellationToken, ): Promise { - return new Promise((resolve) => { + const version = await this._probeCliVersion(cliPath, timeoutMs, cancellationToken); + if (!version) { + return null; + } + + return { + cliPath, + version: version.value, + status: compareCliVersions(version, minimumVersion) >= 0 ? 'supported' : 'unsupported', + }; + } + + private _probeCliVersion( + cliPath: string, + timeoutMs: number, + cancellationToken?: vscode.CancellationToken, + ): Promise { + return new Promise((resolve) => { let childProcess: ChildProcessWithoutNullStreams | undefined; + let termination: Promise | undefined; let output = ''; let outputTooLong = false; let settled = false; let timeout: ReturnType | undefined; let cancellation: vscode.Disposable | undefined; - const settle = (result: CliVersionStatus | null) => { + const settle = (result: CliVersion | null) => { if (settled) { return; } @@ -306,33 +415,36 @@ export class ConfigInfoProvider { resolve(result); }; const reportUnavailable = (error: unknown) => { - if (settled) { + if (settled || termination) { return; } extensionLogOutputChannel.warn(`Unable to probe Aspire CLI version: ${String(error)}`); settle(null); }; + const terminateAndSettle = (description: string) => { + if (settled || termination) { + return; + } + if (!childProcess) { + settle(null); + return; + } + + termination = terminateCliProcess(childProcess, description) + .catch(error => extensionLogOutputChannel.error(`Failed to terminate ${description}: ${String(error)}`)) + .then(() => settle(null)); + }; if (cancellationToken?.isCancellationRequested) { settle(null); return; } timeout = setTimeout(() => { - settle(null); - if (childProcess) { - void terminateCliProcess(childProcess, 'timed-out Aspire CLI version probe').catch(error => { - extensionLogOutputChannel.error(`Failed to terminate timed-out Aspire CLI version probe: ${String(error)}`); - }); - } + terminateAndSettle('timed-out Aspire CLI version probe'); }, Math.min(timeoutMs, cliVersionProbeTimeoutMs)); cancellation = cancellationToken?.onCancellationRequested(() => { - settle(null); - if (childProcess) { - void terminateCliProcess(childProcess, 'cancelled Aspire CLI version probe').catch(error => { - extensionLogOutputChannel.error(`Failed to terminate cancelled Aspire CLI version probe: ${String(error)}`); - }); - } + terminateAndSettle('cancelled Aspire CLI version probe'); }); try { @@ -347,6 +459,9 @@ export class ConfigInfoProvider { output += value; }, exitCallback: code => { + if (termination) { + return; + } if (code !== 0 || outputTooLong) { settle(null); return; @@ -358,18 +473,7 @@ export class ConfigInfoProvider { return; } - const comparison = compareCliVersions(version, minimumVersion); - // A prerelease at the exact minimum core predates the stable version that - // introduced the option, so treat it as known unsupported. Prerelease/dev - // builds with a higher numeric core remain supported because the feature - // already exists on that later release line. - settle({ - cliPath, - version: version.value, - status: comparison > 0 || (comparison === 0 && (!version.isPrerelease || minimumVersion.isPrerelease)) - ? 'supported' - : 'unsupported', - }); + settle(version); }, errorCallback: reportUnavailable, noExtensionVariables: true, @@ -381,6 +485,118 @@ export class ConfigInfoProvider { }); } + private _fetchCliUpdateRecommendation( + cliPath: string, + target: CliPathResolutionTarget, + identityChannelOverride: string | undefined, + cancellationToken?: vscode.CancellationToken, + ): Promise { + return new Promise(resolve => { + let childProcess: ChildProcessWithoutNullStreams | undefined; + let termination: Promise | undefined; + let settled = false; + let timeout: ReturnType | undefined; + let cancellation: vscode.Disposable | undefined; + const settle = (result: CliUpdateRecommendation) => { + if (settled) { + return; + } + + settled = true; + if (timeout) { + clearTimeout(timeout); + } + cancellation?.dispose(); + resolve(result); + }; + const reportUnavailable = (error: unknown) => { + if (settled || termination) { + return; + } + + extensionLogOutputChannel.warn(`Unable to check for Aspire CLI updates: ${String(error)}`); + settle({ status: 'unavailable' }); + }; + const terminateAndSettle = (description: string) => { + if (settled || termination) { + return; + } + if (!childProcess) { + settle({ status: 'unavailable' }); + return; + } + + termination = terminateCliProcess(childProcess, description) + .catch(error => extensionLogOutputChannel.error(`Failed to terminate ${description}: ${String(error)}`)) + .then(() => settle({ status: 'unavailable' })); + }; + if (cancellationToken?.isCancellationRequested) { + settle({ status: 'unavailable' }); + return; + } + + timeout = setTimeout(() => { + terminateAndSettle('timed-out Aspire CLI update probe'); + }, cliUpdateProbeTimeoutMs); + cancellation = cancellationToken?.onCancellationRequested(() => { + terminateAndSettle('cancelled Aspire CLI update probe'); + }); + + const runDoctor = (args: string[], allowNoLogoRetry: boolean) => { + let output = ''; + let stderr = ''; + let outputTooLong = false; + try { + childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + createProcessGroup: true, + workingDirectory: resolveConfigInfoWorkingDirectory(target), + noExtensionVariables: true, + env: nonInteractiveCliEnvironment, + stdoutCallback: value => { + if (output.length + value.length > maxCliUpdateOutputLength) { + outputTooLong = true; + return; + } + output += value; + }, + stderrCallback: value => { + if (stderr.length < maxCliUpdateOutputLength) { + stderr += value.slice(0, maxCliUpdateOutputLength - stderr.length); + } + }, + exitCallback: () => { + if (settled || termination) { + return; + } + if (allowNoLogoRetry && isNoLogoUnsupportedOutput(args, output, stderr)) { + runDoctor(removeRootNoLogoOption(args), false); + return; + } + if (outputTooLong) { + settle({ status: 'unavailable' }); + return; + } + + try { + settle(parseCliUpdateRecommendationOutput(output, identityChannelOverride)); + } + catch (error) { + extensionLogOutputChannel.warn(`Unable to parse Aspire CLI update status: ${String(error)}`); + settle({ status: 'unavailable' }); + } + }, + errorCallback: reportUnavailable, + }); + } + catch (error) { + reportUnavailable(error); + } + }; + + runDoctor(['doctor', '--format', 'json', noLogoOption], true); + }); + } + private _startSharedProbe( cacheKey: string, cliPath: string, @@ -638,7 +854,8 @@ function parseCliVersion(value: string): CliVersion | undefined { // `aspire --version` emits a bare semver-like value, for example: // 13.2.0 // 13.2.0-preview.1.12345.6+abcdef - // Only the bounded numeric core participates in comparison; any other output is unavailable. + // Comparison follows SemVer precedence, including prerelease identifiers, while build metadata + // remains informational. Any other output is unavailable. const normalized = value.trim(); if (normalized.length === 0 || normalized.length > maxCliVersionOutputLength) { return undefined; @@ -655,13 +872,174 @@ function parseCliVersion(value: string): CliVersion | undefined { minor: Number.parseInt(match[2], 10), patch: Number.parseInt(match[3], 10), isPrerelease: match[4] !== undefined, + prereleaseIdentifiers: match[4]?.split('.') ?? [], }; } function compareCliVersions(left: CliVersion, right: CliVersion): number { - return left.major - right.major || + const coreComparison = left.major - right.major || left.minor - right.minor || left.patch - right.patch; + if (coreComparison !== 0) { + return coreComparison; + } + if (left.isPrerelease !== right.isPrerelease) { + return left.isPrerelease ? -1 : 1; + } + + for (let index = 0; index < Math.max(left.prereleaseIdentifiers.length, right.prereleaseIdentifiers.length); index++) { + const leftIdentifier = left.prereleaseIdentifiers[index]; + const rightIdentifier = right.prereleaseIdentifiers[index]; + if (leftIdentifier === undefined || rightIdentifier === undefined) { + return leftIdentifier === undefined ? -1 : 1; + } + if (leftIdentifier === rightIdentifier) { + continue; + } + + const leftIsNumeric = /^\d+$/.test(leftIdentifier); + const rightIsNumeric = /^\d+$/.test(rightIdentifier); + if (leftIsNumeric !== rightIsNumeric) { + return leftIsNumeric ? -1 : 1; + } + if (leftIsNumeric) { + const lengthComparison = leftIdentifier.length - rightIdentifier.length; + if (lengthComparison !== 0) { + return lengthComparison; + } + } + + return leftIdentifier < rightIdentifier ? -1 : 1; + } + + return 0; +} + +export function compareCliVersionValues(left: string, right: string): number | undefined { + const leftVersion = parseCliVersion(left); + const rightVersion = parseCliVersion(right); + return leftVersion && rightVersion ? compareCliVersions(leftVersion, rightVersion) : undefined; +} + +export function parseCliUpdateRecommendationOutput( + output: string, + identityChannelOverride?: string, +): CliUpdateRecommendation { + // `aspire doctor --format json` emits: + // { "checks": [{ "name": "cli-version", "metadata": { + // "currentVersion": "13.5.0", "latestVersion": "13.6.0", + // "latestVersionChannel": "stable" } }] } + // Doctor reports the physical assembly channel. The optional override follows the CLI's own + // environment/sidecar precedence, but it is applied only after the physical binary is proven to + // be a published build. This keeps local/PR/run builds silent even when they emulate stable. + const response = JSON.parse(output.trim()) as { checks?: unknown }; + if (!Array.isArray(response.checks)) { + return { status: 'unavailable' }; + } + + const check = response.checks.find(value => + typeof value === 'object' && + value !== null && + (value as { name?: unknown }).name === 'cli-version') as { metadata?: unknown } | undefined; + if (typeof check?.metadata !== 'object' || check.metadata === null) { + return { status: 'unavailable' }; + } + + const metadata = check.metadata as { + currentVersion?: unknown; + latestVersion?: unknown; + identityChannel?: unknown; + latestVersionChannel?: unknown; + updateCheckError?: unknown; + }; + if (typeof metadata.currentVersion !== 'string') { + return { status: 'unavailable' }; + } + + const currentVersion = parseCliVersion(metadata.currentVersion); + if (!currentVersion) { + return { status: 'unavailable' }; + } + + const physicalChannel = normalizePublishedCliChannel(metadata.identityChannel); + if (!physicalChannel) { + return { status: 'ineligible', currentVersion: currentVersion.value }; + } + const identityChannel = identityChannelOverride + ? normalizePublishedCliChannel(identityChannelOverride) + : physicalChannel; + if (!identityChannel) { + return { status: 'ineligible', currentVersion: currentVersion.value }; + } + if (typeof metadata.updateCheckError === 'string' && metadata.updateCheckError.length > 0) { + return { status: 'unavailable' }; + } + + if (metadata.latestVersion === undefined || metadata.latestVersion === null) { + return { status: 'none', currentVersion: currentVersion.value }; + } + if (typeof metadata.latestVersion !== 'string') { + return { status: 'unavailable' }; + } + + const latestVersion = parseCliVersion(metadata.latestVersion); + if (!latestVersion) { + return { status: 'unavailable' }; + } + + const latestVersionChannel = metadata.latestVersionChannel === undefined + ? (latestVersion.isPrerelease ? 'prerelease' : 'stable') + : normalizeRecommendationChannel(metadata.latestVersionChannel); + if (!latestVersionChannel) { + return { status: 'unavailable' }; + } + + const expectedRecommendationChannel = identityChannel === 'stable' ? 'stable' : 'prerelease'; + return latestVersionChannel === expectedRecommendationChannel + ? { status: 'available', currentVersion: currentVersion.value, version: latestVersion.value } + : { status: 'none', currentVersion: currentVersion.value }; +} + +function normalizePublishedCliChannel(value: unknown): 'stable' | 'daily' | 'staging' | undefined { + if (typeof value !== 'string') { + return undefined; + } + + if (value.length === 0 || value !== value.trim()) { + return undefined; + } + + const channel = value.toLowerCase(); + return channel === 'stable' || channel === 'daily' || channel === 'staging' + ? channel + : undefined; +} + +function normalizeRecommendationChannel(value: unknown): 'stable' | 'prerelease' | undefined { + if (typeof value !== 'string') { + return undefined; + } + + if (value.length === 0 || value !== value.trim()) { + return undefined; + } + + const channel = value.toLowerCase(); + return channel === 'stable' || channel === 'prerelease' + ? channel + : undefined; +} + +function getEnvironmentValue( + environment: Record, + name: string, +): string | undefined { + if (process.platform !== 'win32') { + return environment[name]; + } + + const key = Object.keys(environment).find(candidate => candidate.toUpperCase() === name); + return key ? environment[key] : undefined; } export function parseConfigInfoOutput(output: string): ConfigInfo { diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 66a71625903..8b8f0ddd47d 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -1,43 +1,66 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as strings from '../loc/strings'; -import { CliVersionStatus, CliVersionStatusOptions, ConfigInfoProvider } from './configInfoProvider'; +import { + CliIdentityInfo, + CliUpdateRecommendation, + CliVersionInfo, + compareCliVersionValues, + ConfigInfoProvider, +} from './configInfoProvider'; import { CliPathResolutionTarget } from './cliPathVariables'; import { getComparisonKey } from './paths/comparison'; -export const minimumSupportedAspireCliVersion = '13.5.0'; const updateAspireCliCommand = 'aspire-vscode.updateSelf'; +const versionRefreshIntervalMs = 5 * 60 * 1_000; +const versionFailureRetryMs = 60 * 1_000; +const completedUpdateRefreshIntervalMs = 6 * 60 * 60 * 1_000; +const unavailableRetryBaseMs = 60 * 1_000; +const unavailableRetryMaximumMs = 30 * 60 * 1_000; -type CliVersionProvider = Pick; +type CliVersionProvider = Pick; export interface OutdatedCliNotificationSurface { showWarning(message: string, ...actions: string[]): Thenable; executeCommand(command: string, ...args: unknown[]): Thenable; } +interface CliCheckState { + identity: CliIdentityInfo | undefined; + versionValidUntil: number; + updateStatus: 'complete' | 'ineligible' | 'unavailable' | undefined; + updateValidUntil: number; + failureCount: number; +} + +interface PendingNotification { + target: CliPathResolutionTarget; + cli: CliIdentityInfo; + recommendedVersion: string; +} + const defaultSurface: OutdatedCliNotificationSurface = { showWarning: (message, ...actions) => vscode.window.showWarningMessage(message, ...actions), executeCommand: (command, ...args) => vscode.commands.executeCommand(command, ...args), }; /** - * Warns when an Aspire feature actively selects a CLI older than the release that fixed AppHost - * startup issue #17354. Checks are session-scoped and best effort: an unavailable version probe - * remains silent, while replacing a CLI in place with a different version is observed. + * Checks actively used Aspire CLIs for a same-channel update. Version sampling is cheap and + * periodic; the heavyweight doctor adapter is limited to one active probe and cached independently. */ export class OutdatedCliNotifier implements vscode.Disposable { - private static readonly _maxConcurrentProbes = 4; - + private readonly _stateByCliPath = new Map(); private readonly _notifiedCliVersions = new Set(); + private readonly _inFlightByCliPath = new Map>(); private readonly _cancellationSource = new vscode.CancellationTokenSource(); - private readonly _inFlightByCliPath = new Map>(); - private readonly _probeWaiters: Array<() => void> = []; - private _activeProbeCount = 0; + private readonly _versionLimiter = new AsyncLimiter(4); + private _doctorActive = false; private _disposed = false; constructor( private readonly _versionProvider: CliVersionProvider, private readonly _surface: OutdatedCliNotificationSurface = defaultSurface, + private readonly _now: () => number = Date.now, ) { } @@ -47,99 +70,247 @@ export class OutdatedCliNotifier implements vscode.Disposable { } const cliPathKey = getComparisonKey(path.normalize(cliPath)); + if ((this._stateByCliPath.get(cliPathKey)?.versionValidUntil ?? 0) > this._now()) { + return; + } + const existingProbe = this._inFlightByCliPath.get(cliPathKey); if (existingProbe) { await existingProbe; return; } - const probe = this._runProbe(target, cliPath).finally(() => { + let probe!: Promise; + probe = this._checkForUpdate(target, cliPathKey, cliPath).finally(() => { if (this._inFlightByCliPath.get(cliPathKey) === probe) { this._inFlightByCliPath.delete(cliPathKey); } }); this._inFlightByCliPath.set(cliPathKey, probe); - await probe; - } + const notification = await probe; + if (this._disposed || !notification) { + return; + } - private async _runProbe(target: CliPathResolutionTarget, cliPath: string): Promise { - if (!await this._acquireProbeSlot()) { + const notificationKey = `${getComparisonKey(path.normalize(notification.cli.cliPath))}\u0000${notification.cli.version}`; + if (this._notifiedCliVersions.has(notificationKey)) { return; } - let result: CliVersionStatus | null; - try { - const options: CliVersionStatusOptions = { + this._notifiedCliVersions.add(notificationKey); + const selection = await this._surface.showWarning( + strings.outdatedAspireCliWarning( + notification.cli.version, + notification.cli.cliPath, + notification.recommendedVersion), + strings.updateAspireCliAction); + if (this._disposed || selection !== strings.updateAspireCliAction) { + return; + } + + // This user-initiated guard intentionally bypasses the five-minute cache. + const currentVersionProbe = await this._versionLimiter.run(() => + this._versionProvider.getCliVersion({ + target: notification.target, + cliPath: notification.cli.cliPath, + cancellationToken: this._cancellationSource.token, + })); + if (!currentVersionProbe.executed || this._disposed) { + return; + } + if (currentVersionProbe.value && currentVersionProbe.value.version !== notification.cli.version) { + return; + } + + await this._surface.executeCommand(updateAspireCliCommand, notification.target, notification.cli.cliPath); + } + + private async _checkForUpdate( + target: CliPathResolutionTarget, + cliPathKey: string, + cliPath: string, + ): Promise { + const versionProbe = await this._versionLimiter.run(() => + this._versionProvider.getCliIdentity({ target, cliPath, cancellationToken: this._cancellationSource.token, + })); + if (!versionProbe.executed || this._disposed) { + return undefined; + } + + const now = this._now(); + const previous = this._stateByCliPath.get(cliPathKey); + const identity = versionProbe.value; + if (!identity) { + this._stateByCliPath.set(cliPathKey, { + identity: previous?.identity, + versionValidUntil: now + versionFailureRetryMs, + updateStatus: previous?.updateStatus, + updateValidUntil: previous?.updateValidUntil ?? 0, + failureCount: previous?.failureCount ?? 0, + }); + return undefined; + } + + const identityChanged = !areCliIdentitiesEqual(previous?.identity, identity); + const state: CliCheckState = identityChanged + ? { + identity, + versionValidUntil: now + versionRefreshIntervalMs, + updateStatus: undefined, + updateValidUntil: 0, + failureCount: 0, + } + : { + identity, + versionValidUntil: now + versionRefreshIntervalMs, + updateStatus: previous?.updateStatus, + updateValidUntil: previous?.updateValidUntil ?? 0, + failureCount: previous?.failureCount ?? 0, }; - result = await this._versionProvider.getCliVersionStatus(minimumSupportedAspireCliVersion, options); + this._stateByCliPath.set(cliPathKey, state); + + if (!identityChanged && + (state.updateStatus === 'ineligible' || + (state.updateStatus !== undefined && state.updateValidUntil > now))) { + return undefined; + } + + // Do not queue paths behind a potentially slow doctor. The skipped path keeps its + // five-minute version clock and can try again after the active doctor completes. + if (this._doctorActive) { + return undefined; + } + + this._doctorActive = true; + let recommendationProbe: LimiterResult; + try { + recommendationProbe = await this._versionLimiter.run(() => + this._versionProvider.getCliUpdateRecommendation({ + target, + cliPath, + identityChannelOverride: identity.identityChannelOverride, + cancellationToken: this._cancellationSource.token, + })); } finally { - this._releaseProbeSlot(); + this._doctorActive = false; } - if (this._disposed || result?.status !== 'unsupported') { - return; + if (!recommendationProbe.executed || this._disposed) { + return undefined; } - const notificationKey = `${getComparisonKey(path.normalize(result.cliPath))}\u0000${result.version}`; - if (this._notifiedCliVersions.has(notificationKey)) { - return; + const recommendation = recommendationProbe.value; + if (recommendation.status === 'ineligible') { + state.updateStatus = 'ineligible'; + state.updateValidUntil = Number.POSITIVE_INFINITY; + state.failureCount = 0; + return undefined; + } + if (recommendation.status === 'unavailable' || + compareCliVersionValues(identity.version, recommendation.currentVersion) !== 0) { + this._recordUnavailable(state); + return undefined; } - // Mark the executable/version before showing UI so concurrent folder probes cannot create - // duplicate notifications while the first warning is still awaiting user input. - this._notifiedCliVersions.add(notificationKey); - const selection = await this._surface.showWarning( - strings.outdatedAspireCliWarning(result.version, result.cliPath, minimumSupportedAspireCliVersion), - strings.updateAspireCliAction); - if (!this._disposed && selection === strings.updateAspireCliAction) { - await this._surface.executeCommand(updateAspireCliCommand, target, result.cliPath); + state.updateStatus = 'complete'; + state.updateValidUntil = this._now() + completedUpdateRefreshIntervalMs; + state.failureCount = 0; + if (recommendation.status !== 'available') { + return undefined; } + + const comparison = compareCliVersionValues(identity.version, recommendation.version); + return comparison !== undefined && comparison < 0 + ? { target, cli: identity, recommendedVersion: recommendation.version } + : undefined; + } + + private _recordUnavailable(state: CliCheckState): void { + state.failureCount = state.updateStatus === 'unavailable' ? state.failureCount + 1 : 1; + state.updateStatus = 'unavailable'; + state.updateValidUntil = this._now() + Math.min( + unavailableRetryBaseMs * 2 ** (state.failureCount - 1), + unavailableRetryMaximumMs); } - private async _acquireProbeSlot(): Promise { + dispose(): void { if (this._disposed) { - return false; + return; } - if (this._activeProbeCount < OutdatedCliNotifier._maxConcurrentProbes) { - this._activeProbeCount++; - return true; + + this._disposed = true; + this._cancellationSource.cancel(); + this._cancellationSource.dispose(); + this._versionLimiter.dispose(); + this._inFlightByCliPath.clear(); + this._stateByCliPath.clear(); + this._notifiedCliVersions.clear(); + } +} + +function areCliIdentitiesEqual(left: CliIdentityInfo | undefined, right: CliIdentityInfo): boolean { + return left?.version === right.version && + left.identityChannelOverride === right.identityChannelOverride; +} + +type LimiterResult = + | { executed: true; value: T } + | { executed: false }; + +class AsyncLimiter implements vscode.Disposable { + private readonly _waiters: Array<(acquired: boolean) => void> = []; + private _activeCount = 0; + private _disposed = false; + + constructor(private readonly _maximumConcurrency: number) { + } + + async run(action: () => Promise): Promise> { + if (!await this._acquire()) { + return { executed: false }; + } + if (this._disposed) { + this._release(); + return { executed: false }; } - await new Promise(resolve => this._probeWaiters.push(resolve)); + try { + return { executed: true, value: await action() }; + } + finally { + this._release(); + } + } + + private async _acquire(): Promise { if (this._disposed) { return false; } + if (this._activeCount < this._maximumConcurrency) { + this._activeCount++; + return true; + } - // The releasing probe transfers its slot directly to this waiter. Do not increment here: - // keeping the count at the limit prevents a new arrival from taking the same slot before - // this continuation resumes. - return true; + return await new Promise(resolve => this._waiters.push(resolve)); } - private _releaseProbeSlot(): void { - const waiter = this._probeWaiters.shift(); + private _release(): void { + const waiter = this._waiters.shift(); if (waiter) { - waiter(); + waiter(true); return; } - this._activeProbeCount--; + this._activeCount--; } dispose(): void { - if (this._disposed) { - return; - } - this._disposed = true; - this._cancellationSource.cancel(); - this._cancellationSource.dispose(); - while (this._probeWaiters.length > 0) { - this._probeWaiters.shift()?.(); + while (this._waiters.length > 0) { + this._waiters.shift()?.(false); } - this._notifiedCliVersions.clear(); } } diff --git a/extension/src/views/ResourceCommandArgumentsLoader.ts b/extension/src/views/ResourceCommandArgumentsLoader.ts index 2b035e9b3ee..663dddecaab 100644 --- a/extension/src/views/ResourceCommandArgumentsLoader.ts +++ b/extension/src/views/ResourceCommandArgumentsLoader.ts @@ -15,6 +15,7 @@ import { } from './ResourceCommandArguments'; import { nonInteractiveCliEnvironment } from '../utils/environment'; import { getCliPathTargetForUri } from '../utils/cliPathVariables'; +import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; export interface ResourceCommandArgumentLoaderContext { cliExecutionProvider: AspireTerminalProvider; @@ -47,7 +48,9 @@ async function loadResourceCommandArgumentInputs( { location: vscode.ProgressLocation.Window, title: resourceCommandLoadingDynamicInputs }, async () => { try { - const cliPath = await context.cliExecutionProvider.getAspireCliExecutablePath(getCliPathTargetForUri(vscode.Uri.file(context.appHostPath!))); + const target = getCliPathTargetForUri(vscode.Uri.file(context.appHostPath!)); + const cliPath = await context.cliExecutionProvider.getAspireCliExecutablePath(target); + reportCliResolvedForOperation(target, cliPath); const args = ['resource', context.resourceName, context.commandName, '--load-arguments', '--non-interactive', '--apphost', context.appHostPath!, noLogoOption]; args.push(...buildResourceCommandCliArgs(values)); From 8670c6e6bab1970116bb646b03968304505561b2 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 12:23:19 -0700 Subject: [PATCH 09/31] Require verified CLI before self-update Suppress the warning action when the exact CLI path can no longer report the version that triggered the notification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/test/outdatedCliNotifier.test.ts | 4 ++-- extension/src/utils/outdatedCliNotifier.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index aa08a85de0e..28134d4aad2 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -301,7 +301,7 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); - test('stale warning action is suppressed but inconclusive recheck remains usable', async () => { + test('stale or inconclusive warning actions are suppressed', async () => { const { notifier, versionProvider, surface } = createNotifier(); let resolveSelection!: (selection: string | undefined) => void; surface.selectionPromise = new Promise(resolve => resolveSelection = resolve); @@ -320,7 +320,7 @@ suite('outdatedCliNotifier', () => { second.surface.selection = strings.updateAspireCliAction; second.versionProvider.currentVersion = null; await second.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - assert.strictEqual(second.surface.commands.length, 1); + assert.deepStrictEqual(second.surface.commands, []); second.notifier.dispose(); notifier.dispose(); }); diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 8b8f0ddd47d..b8f7f74deaa 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -118,7 +118,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { if (!currentVersionProbe.executed || this._disposed) { return; } - if (currentVersionProbe.value && currentVersionProbe.value.version !== notification.cli.version) { + if (!currentVersionProbe.value || currentVersionProbe.value.version !== notification.cli.version) { return; } From 225341ecb7dad29aa1c80e84a6c34a040bf79400 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 12:52:21 -0700 Subject: [PATCH 10/31] Cover general stable CLI updates Pin that update notifications compare against the installed channel without a fixed minimum version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/test/configInfoProvider.test.ts | 3 +++ .../src/test/outdatedCliNotifier.test.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts index 4179c627ced..0153f4560c2 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -296,6 +296,9 @@ suite('configInfoProvider tests', () => { }); test('parseCliUpdateRecommendationOutput accepts stable and prerelease recommendations', () => { + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.4.0', '15.3.2')), + { status: 'available', currentVersion: '13.4.0', version: '15.3.2' }); assert.deepStrictEqual(parseCliUpdateRecommendationOutput( createDoctorVersionOutput('13.5.0', '13.6.0')), { status: 'available', currentVersion: '13.5.0', version: '13.6.0' }); diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index 28134d4aad2..dfdd0c8ee68 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -118,6 +118,26 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); + test('warns when stable 13.4.0 is behind stable 15.3.2', async () => { + const { notifier, versionProvider, surface } = createNotifier(); + versionProvider.identity = { + cliPath: '/cli/aspire', + version: '13.4.0', + }; + versionProvider.recommendation = { + status: 'available', + currentVersion: '13.4.0', + version: '15.3.2', + }; + + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual( + surface.warnings[0].message, + 'Aspire CLI 13.4.0 at /cli/aspire has a newer version available for its current channel: 15.3.2.'); + notifier.dispose(); + }); + test('uses five-minute version and six-hour update refresh intervals', async () => { let now = 0; const { notifier, versionProvider, surface } = createNotifier(() => now); From c10a0cfb84aa49b150001d04e654fd549867bb07 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 14:54:27 -0700 Subject: [PATCH 11/31] Resample CLIs for active follow operations Re-report exact CLI paths while ps and describe streams remain active so replacements are observed on the five-minute version cadence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/data/AppHostDataRepository.ts | 15 +++- extension/src/data/appHostPsPoller.ts | 16 ++++- .../src/test/appHostDataRepository.test.ts | 68 ++++++++++++++++++- .../src/test/outdatedCliNotifier.test.ts | 40 +++++++++++ extension/src/utils/cliOperationResolution.ts | 18 +++++ extension/src/utils/outdatedCliNotifier.ts | 10 +-- 6 files changed, 160 insertions(+), 7 deletions(-) diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index ba8ad81d43b..48e166452bf 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -18,7 +18,7 @@ import { isMatchingAppHostInstance, isMatchingAppHostPath, isPathInWorkspace } f import { AppHostPsPoller } from './appHostPsPoller'; import { filterResourceCommandStatusOutput } from './resourceCommandStatusOutput'; import { getCliPathTargetForUri } from '../utils/cliPathVariables'; -import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; +import { reportCliResolvedForOperation, startCliOperationResolutionHeartbeat } from '../utils/cliOperationResolution'; export * from './appHostCliContracts'; export { shortenPath, shortenPaths }; @@ -48,6 +48,7 @@ interface DescribeStream { nonJsonLines: string[]; stderr: string; restartTimer: ReturnType | undefined; + cliResolutionHeartbeat: vscode.Disposable | undefined; restartDelay: number; version: number; } @@ -1048,6 +1049,7 @@ export class AppHostDataRepository { nonJsonLines: [], stderr: '', restartTimer: undefined, + cliResolutionHeartbeat: undefined, // A fresh stream restarts after 5s; a restart carries the backed-off delay forward via // `initialRestartDelay` so repeated no-data exits grow the interval (5s -> 10s -> 20s ...) // instead of hammering the CLI every 5s. Each stream is single-use, so the backoff has to @@ -1143,6 +1145,8 @@ export class AppHostDataRepository { } extensionLogOutputChannel.info(`aspire describe --follow (--apphost ${appHostPath}) exited with code ${code}`); + stream.cliResolutionHeartbeat?.dispose(); + stream.cliResolutionHeartbeat = undefined; stream.process = undefined; if (this._disposed) { @@ -1211,6 +1215,8 @@ export class AppHostDataRepository { // surfaces it through the shared describe banner; a non-selected peer is logged only so it // can't masquerade as the selected host's error. extensionLogOutputChannel.warn(`aspire describe --follow --apphost ${appHostPath} error: ${error.message}`); + stream.cliResolutionHeartbeat?.dispose(); + stream.cliResolutionHeartbeat = undefined; stream.process = undefined; stream.resources.clear(); if (isMatchingAppHostPath(appHostPath, this._workspaceAppHostPath)) { @@ -1232,6 +1238,11 @@ export class AppHostDataRepository { } }); stream.process = describeProcess; + const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath)); + stream.cliResolutionHeartbeat = startCliOperationResolutionHeartbeat( + target, + cliPath, + () => this._describeStreams.get(appHostPath) === stream && stream.process === describeProcess); } private _scheduleDescribeRestart(appHostPath: string, stream: DescribeStream): void { @@ -1398,6 +1409,8 @@ export class AppHostDataRepository { clearTimeout(stream.restartTimer); stream.restartTimer = undefined; } + stream.cliResolutionHeartbeat?.dispose(); + stream.cliResolutionHeartbeat = undefined; if (stream.process) { const childProcess = stream.process; stream.process = undefined; diff --git a/extension/src/data/appHostPsPoller.ts b/extension/src/data/appHostPsPoller.ts index 4b6832549d3..6a512f251d5 100644 --- a/extension/src/data/appHostPsPoller.ts +++ b/extension/src/data/appHostPsPoller.ts @@ -6,7 +6,7 @@ import { extensionLogOutputChannel } from '../utils/logging'; import { errorFetchingAppHosts } from '../loc/strings'; import { AppHostCliRunner, LimitedOutputBuffer, oneShotOutputBufferLimit } from './appHostCliRunner'; import { windowCliPathTarget } from '../utils/cliPathVariables'; -import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; +import { reportCliResolvedForOperation, startCliOperationResolutionHeartbeat } from '../utils/cliOperationResolution'; export interface PsOutput { readonly stdout: string; @@ -36,6 +36,7 @@ export class AppHostPsPoller implements vscode.Disposable { private _pollingInterval: ReturnType | undefined; private _psProcesses = new Set(); + private _psFollowCliResolutionHeartbeat: vscode.Disposable | undefined; private _psPollingGeneration = 0; private _psFetchVersion = 0; private _supportsPsFollow = true; @@ -119,6 +120,8 @@ export class AppHostPsPoller implements vscode.Disposable { this._pollingInterval = undefined; extensionLogOutputChannel.info(`aspire ps polling stopped`); } + this._psFollowCliResolutionHeartbeat?.dispose(); + this._psFollowCliResolutionHeartbeat = undefined; for (const psProcess of this._psProcesses) { void terminateCliProcess(psProcess, 'aspire ps').catch(error => { extensionLogOutputChannel.error(`Failed to terminate aspire ps: ${String(error)}`); @@ -164,9 +167,14 @@ export class AppHostPsPoller implements vscode.Disposable { reportCliResolvedForOperation(windowCliPathTarget, cliPath); let psProcess: ChildProcessWithoutNullStreams | undefined; + let cliResolutionHeartbeat: vscode.Disposable | undefined; let psProcessCompletedSynchronously = false; let callbackInvoked = false; const removePsProcess = () => { + cliResolutionHeartbeat?.dispose(); + if (this._psFollowCliResolutionHeartbeat === cliResolutionHeartbeat) { + this._psFollowCliResolutionHeartbeat = undefined; + } if (psProcess) { this._psProcesses.delete(psProcess); } else { @@ -236,6 +244,12 @@ export class AppHostPsPoller implements vscode.Disposable { }); if (!psProcessCompletedSynchronously) { this._psProcesses.add(psProcess); + this._psFollowCliResolutionHeartbeat?.dispose(); + cliResolutionHeartbeat = startCliOperationResolutionHeartbeat( + windowCliPathTarget, + cliPath, + () => this._isCurrentPsFetch(fetchVersion) && this._psProcesses.has(psProcess!)); + this._psFollowCliResolutionHeartbeat = cliResolutionHeartbeat; } this._psFollowStartPending = false; diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 586c1950400..746ee584441 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -2777,6 +2777,7 @@ suite('AppHostDataRepository', () => { }); } }); + const context = await startTwoFolderDescribeStreams(); try { @@ -2791,6 +2792,40 @@ suite('AppHostDataRepository', () => { } }); + test('active describe streams periodically re-report their exact CLI paths', async () => { + const resolutions: string[] = []; + const resolutionSubscription = onDidResolveCliForOperation(resolution => { + if (resolution.target.kind === 'workspaceFolder') { + resolutions.push(resolution.cliPath); + } + }); + const clock = sinon.useFakeTimers({ shouldAdvanceTime: true }); + let context: Awaited> | undefined; + + try { + context = await startTwoFolderDescribeStreams(); + assert.deepStrictEqual(resolutions.sort(), ['/cli/peer/aspire', '/cli/selected/aspire']); + + clock.tick(5 * 60 * 1_000); + + assert.deepStrictEqual(resolutions.sort(), [ + '/cli/peer/aspire', + '/cli/peer/aspire', + '/cli/selected/aspire', + '/cli/selected/aspire', + ]); + + context.dispose(); + clock.tick(5 * 60 * 1_000); + assert.strictEqual(resolutions.length, 4); + } + finally { + clock.restore(); + resolutionSubscription.dispose(); + context?.dispose(); + } + }); + test('working peer CLI does not clear the selected AppHost compatibility error', async () => { const context = await startTwoFolderDescribeStreams(); @@ -6400,6 +6435,35 @@ suite('AppHostDataRepository global polling', () => { } }); + test('active ps follow periodically re-reports its exact CLI path', async () => { + const clock = sinon.useFakeTimers({ shouldAdvanceTime: true }); + const repository = new AppHostDataRepository(terminalProvider); + const resolutions: string[] = []; + const resolutionSubscription = onDidResolveCliForOperation(resolution => { + if (resolution.target.kind === 'window') { + resolutions.push(resolution.cliPath); + } + }); + + try { + repository.activate(); + repository.setViewMode('global'); + repository.setPanelVisible(true); + await waitForCondition(() => resolutions.length === 1, 'global ps follow did not report its CLI'); + clock.tick(5 * 60 * 1_000); + assert.deepStrictEqual(resolutions, ['aspire', 'aspire']); + + repository.setPanelVisible(false); + clock.tick(5 * 60 * 1_000); + assert.strictEqual(resolutions.length, 2); + } + finally { + clock.restore(); + resolutionSubscription.dispose(); + repository.dispose(); + } + }); + test('switching into global view while ps is already polling clears the loading spinner', async () => { const repository = new AppHostDataRepository(terminalProvider); @@ -7186,7 +7250,9 @@ suite('AppHostDataRepository global polling', () => { await waitForMicrotasks(); assert.strictEqual(spawnStub.calledTwice, true); - assert.strictEqual(clock.countTimers(), timerCountBeforeFallback + 1); + // The fallback polling interval replaces the active follow heartbeat, so the total + // timer count stays constant instead of retaining both. + assert.strictEqual(clock.countTimers(), timerCountBeforeFallback); } finally { repository.dispose(); clock.restore(); diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index dfdd0c8ee68..9ae348297a2 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -1,4 +1,5 @@ import * as assert from 'assert'; +import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as strings from '../loc/strings'; import { @@ -10,6 +11,7 @@ import { } from '../utils/configInfoProvider'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier'; +import { onDidResolveCliForOperation, startCliOperationResolutionHeartbeat } from '../utils/cliOperationResolution'; suite('outdatedCliNotifier', () => { class FakeVersionProvider { @@ -171,6 +173,44 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); + test('active-operation heartbeat rechecks five minutes from check start', async () => { + const clock = sinon.useFakeTimers({ now: 0 }); + const { notifier, versionProvider } = createNotifier(() => Date.now()); + versionProvider.recommendation = { + status: 'none', + currentVersion: '13.5.0', + }; + let resolveIdentity!: (identity: CliIdentityInfo | null) => void; + versionProvider.identityPromise = new Promise(resolve => resolveIdentity = resolve); + const heartbeatChecks: Promise[] = []; + const subscription = onDidResolveCliForOperation(resolution => { + heartbeatChecks.push(notifier.notifyIfOutdated(resolution.target, resolution.cliPath)); + }); + const heartbeat = startCliOperationResolutionHeartbeat( + windowCliPathTarget, + '/cli/aspire', + () => true); + + try { + const initialCheck = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await clock.tickAsync(1_000); + resolveIdentity(versionProvider.identity); + await initialCheck; + versionProvider.identityPromise = undefined; + + await clock.tickAsync(5 * 60 * 1_000 - 1_000); + await Promise.all(heartbeatChecks); + + assert.strictEqual(versionProvider.identityCalls.length, 2); + } + finally { + heartbeat.dispose(); + subscription.dispose(); + notifier.dispose(); + clock.restore(); + } + }); + test('samples version independently while unavailable doctor checks back off', async () => { let now = 0; const { notifier, versionProvider } = createNotifier(() => now); diff --git a/extension/src/utils/cliOperationResolution.ts b/extension/src/utils/cliOperationResolution.ts index 8cc27899d3a..dc84d4ff889 100644 --- a/extension/src/utils/cliOperationResolution.ts +++ b/extension/src/utils/cliOperationResolution.ts @@ -6,6 +6,7 @@ export interface CliOperationResolution { cliPath: string; } +const cliOperationResolutionRefreshIntervalMs = 5 * 60 * 1_000; const cliOperationResolutionEmitter = new vscode.EventEmitter(); /** @@ -17,3 +18,20 @@ export const onDidResolveCliForOperation = cliOperationResolutionEmitter.event; export function reportCliResolvedForOperation(target: CliPathResolutionTarget, cliPath: string): void { cliOperationResolutionEmitter.fire({ target, cliPath }); } + +/** + * Re-reports a CLI while its long-lived operation remains active so version-cache expiry can + * observe an executable replaced in place without probing paths whose operations have stopped. + */ +export function startCliOperationResolutionHeartbeat( + target: CliPathResolutionTarget, + cliPath: string, + isActive: () => boolean, +): vscode.Disposable { + const timer = setInterval(() => { + if (isActive()) { + reportCliResolvedForOperation(target, cliPath); + } + }, cliOperationResolutionRefreshIntervalMs); + return new vscode.Disposable(() => clearInterval(timer)); +} diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index b8f7f74deaa..6f2716afc4c 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -80,8 +80,9 @@ export class OutdatedCliNotifier implements vscode.Disposable { return; } + const checkStartedAt = this._now(); let probe!: Promise; - probe = this._checkForUpdate(target, cliPathKey, cliPath).finally(() => { + probe = this._checkForUpdate(target, cliPathKey, cliPath, checkStartedAt).finally(() => { if (this._inFlightByCliPath.get(cliPathKey) === probe) { this._inFlightByCliPath.delete(cliPathKey); } @@ -129,6 +130,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { target: CliPathResolutionTarget, cliPathKey: string, cliPath: string, + checkStartedAt: number, ): Promise { const versionProbe = await this._versionLimiter.run(() => this._versionProvider.getCliIdentity({ @@ -146,7 +148,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { if (!identity) { this._stateByCliPath.set(cliPathKey, { identity: previous?.identity, - versionValidUntil: now + versionFailureRetryMs, + versionValidUntil: checkStartedAt + versionFailureRetryMs, updateStatus: previous?.updateStatus, updateValidUntil: previous?.updateValidUntil ?? 0, failureCount: previous?.failureCount ?? 0, @@ -158,14 +160,14 @@ export class OutdatedCliNotifier implements vscode.Disposable { const state: CliCheckState = identityChanged ? { identity, - versionValidUntil: now + versionRefreshIntervalMs, + versionValidUntil: checkStartedAt + versionRefreshIntervalMs, updateStatus: undefined, updateValidUntil: 0, failureCount: 0, } : { identity, - versionValidUntil: now + versionRefreshIntervalMs, + versionValidUntil: checkStartedAt + versionRefreshIntervalMs, updateStatus: previous?.updateStatus, updateValidUntil: previous?.updateValidUntil ?? 0, failureCount: previous?.failureCount ?? 0, From b66564d5855875545c0ea199d784290f9e692ac3 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 15:18:19 -0700 Subject: [PATCH 12/31] Bound Aspire CLI identity sampling Apply the version-probe timeout and cancellation budget to physical sidecar discovery so inaccessible paths cannot retain notifier capacity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/test/configInfoProvider.test.ts | 58 ++++++++++++ extension/src/utils/configInfoProvider.ts | 93 ++++++++++++++++--- 2 files changed, 139 insertions(+), 12 deletions(-) diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts index 0153f4560c2..c6774585310 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -485,6 +485,64 @@ suite('configInfoProvider tests', () => { } }); + test('getCliIdentity applies its remaining timeout to sidecar sampling', async () => { + const clock = sinon.useFakeTimers(); + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { + assert.deepStrictEqual(args, ['--version']); + options?.stdoutCallback?.('13.6.0'); + options?.exitCallback?.(0); + return {} as ChildProcessWithoutNullStreams; + }); + sinon.stub(fs.promises, 'realpath').returns(new Promise(() => { })); + const provider = new ConfigInfoProvider(terminalProvider); + + try { + const identity = provider.getCliIdentity({ + cliPath: '/network/aspire', + timeoutMs: 1_000, + }); + await clock.tickAsync(1_000); + + assert.strictEqual(await identity, null); + } + finally { + clock.restore(); + } + }); + + test('getCliIdentity cancels sidecar sampling after the version probe', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { + assert.deepStrictEqual(args, ['--version']); + options?.stdoutCallback?.('13.6.0'); + options?.exitCallback?.(0); + return {} as ChildProcessWithoutNullStreams; + }); + const realpathStub = sinon.stub(fs.promises, 'realpath').returns(new Promise(() => { })); + const cancellation = new vscode.CancellationTokenSource(); + const provider = new ConfigInfoProvider(terminalProvider); + + const identity = provider.getCliIdentity({ + cliPath: '/network/aspire', + cancellationToken: cancellation.token, + }); + while (!realpathStub.called) { + await new Promise(resolve => setImmediate(resolve)); + } + cancellation.cancel(); + + assert.strictEqual(await identity, null); + assert.strictEqual(realpathStub.calledOnceWith('/network/aspire'), true); + cancellation.dispose(); + }); + test('getCliUpdateRecommendation accepts structured doctor output on a nonzero exit', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => '/unused/aspire', diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index e317188105d..d7ecd8caf2d 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -85,6 +85,10 @@ export interface CliIdentityInfo extends CliVersionInfo { identityChannelOverride?: string; } +type CliIdentityChannelOverrideResult = + | { status: 'available'; value?: string } + | { status: 'unavailable' }; + export type CliUpdateRecommendation = | { status: 'available'; currentVersion: string; version: string } | { status: 'none'; currentVersion: string } @@ -254,14 +258,29 @@ export class ConfigInfoProvider { * of its assembly identity: inherited environment first, then the install sidecar. */ async getCliIdentity(options?: CliVersionStatusOptions): Promise { - const cli = await this.getCliVersion(options); + const startTime = Date.now(); + const timeoutMs = Math.min(options?.timeoutMs ?? cliVersionProbeTimeoutMs, cliVersionProbeTimeoutMs); + const cli = await this.getCliVersion({ ...options, timeoutMs }); if (!cli || options?.cancellationToken?.isCancellationRequested) { return null; } + const remainingTimeoutMs = timeoutMs - (Date.now() - startTime); + if (remainingTimeoutMs <= 0) { + return null; + } + + const channelOverride = await this._getIdentityChannelOverride( + cli.cliPath, + remainingTimeoutMs, + options?.cancellationToken); + if (channelOverride.status === 'unavailable') { + return null; + } + return { ...cli, - identityChannelOverride: await this._getIdentityChannelOverride(cli.cliPath), + identityChannelOverride: channelOverride.value, }; } @@ -336,14 +355,37 @@ export class ConfigInfoProvider { ?? 'unavailable'; } - private async _getIdentityChannelOverride(cliPath: string): Promise { + private async _getIdentityChannelOverride( + cliPath: string, + timeoutMs: number, + cancellationToken?: vscode.CancellationToken, + ): Promise { + if (cancellationToken?.isCancellationRequested) { + return { status: 'unavailable' }; + } + const environment = this._terminalProvider.createEnvironment(undefined, undefined, true, cliPath) as Record; const environmentChannel = getEnvironmentValue(environment, 'ASPIRE_CLI_CHANNEL'); if (environmentChannel) { - return environmentChannel.toLowerCase(); + return { status: 'available', value: environmentChannel.toLowerCase() }; } - try { + const abortController = new AbortController(); + let timeout: ReturnType | undefined; + let cancellation: vscode.Disposable | undefined; + let completeUnavailable!: () => void; + const unavailable = new Promise(resolve => { + completeUnavailable = () => resolve({ status: 'unavailable' }); + }); + const abort = () => { + abortController.abort(); + completeUnavailable(); + }; + timeout = setTimeout(abort, timeoutMs); + cancellation = cancellationToken?.onCancellationRequested(abort); + + const readOverride = async (): Promise => { + try { // Install routes write: // /.aspire-install.json // { "source": "script", "channel": "stable", ... } @@ -357,17 +399,44 @@ export class ConfigInfoProvider { // Keep the invocation path when it cannot be canonicalized. The CLI itself uses // the same fallback when resolving its process path. } + if (abortController.signal.aborted) { + return { status: 'unavailable' }; + } + const sidecarPath = path.join(path.dirname(physicalCliPath), '.aspire-install.json'); if ((await stat(sidecarPath)).size > maxCliIdentitySidecarLength) { - return undefined; + return { status: 'available' }; + } + if (abortController.signal.aborted) { + return { status: 'unavailable' }; + } + + const sidecar = JSON.parse(await readFile(sidecarPath, { + encoding: 'utf8', + signal: abortController.signal, + })) as { channel?: unknown }; + return { + status: 'available', + value: typeof sidecar.channel === 'string' && sidecar.channel.length > 0 + ? sidecar.channel.toLowerCase() + : undefined, + }; } - const sidecar = JSON.parse(await readFile(sidecarPath, 'utf8')) as { channel?: unknown }; - return typeof sidecar.channel === 'string' && sidecar.channel.length > 0 - ? sidecar.channel.toLowerCase() - : undefined; + catch { + return abortController.signal.aborted + ? { status: 'unavailable' } + : { status: 'available' }; + } + }; + + try { + return await Promise.race([readOverride(), unavailable]); } - catch { - return undefined; + finally { + if (timeout) { + clearTimeout(timeout); + } + cancellation?.dispose(); } } From a853391783fb503436ddd83f04f5c33bfea5de0e Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 16:18:36 -0700 Subject: [PATCH 13/31] Narrow CLI update recommendation options Do not advertise a per-call timeout that the fixed-budget Doctor adapter does not support. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/utils/configInfoProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index d7ecd8caf2d..858877dd000 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -72,9 +72,9 @@ export interface CliVersionStatusOptions { timeoutMs?: number; } -export interface CliUpdateRecommendationOptions extends CliVersionStatusOptions { +export type CliUpdateRecommendationOptions = Omit & { identityChannelOverride?: string; -} +}; export interface CliVersionInfo { cliPath: string; From b9bffeccbe00242ef24e05556d65c2e8f44c798b Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 17:04:05 -0700 Subject: [PATCH 14/31] Persist outdated CLI warning suppression Offer Don't Show Again for an exact CLI path and version while allowing a different installed version to be evaluated later. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/extension.ts | 2 +- .../src/test/outdatedCliNotifier.test.ts | 93 ++++++++++++++++++- extension/src/utils/outdatedCliNotifier.ts | 66 ++++++++++++- 3 files changed, 152 insertions(+), 9 deletions(-) diff --git a/extension/src/extension.ts b/extension/src/extension.ts index cc926d98398..438d22e899e 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -103,7 +103,7 @@ export async function activate(context: vscode.ExtensionContext) { terminalProvider.closeAllOpenAspireTerminals(); const configInfoProvider = new ConfigInfoProvider(terminalProvider); - const outdatedCliNotifier = new OutdatedCliNotifier(configInfoProvider); + const outdatedCliNotifier = new OutdatedCliNotifier(configInfoProvider, undefined, Date.now, context.globalState); context.subscriptions.push(outdatedCliNotifier); context.subscriptions.push(onDidResolveCliForOperation(({ target, cliPath }) => { void outdatedCliNotifier.notifyIfOutdated(target, cliPath).catch(error => { diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index 9ae348297a2..13ec2094a54 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -67,7 +67,7 @@ suite('outdatedCliNotifier', () => { } } - function createNotifier(now: () => number = Date.now): { + function createNotifier(now: () => number = Date.now, globalState?: vscode.Memento): { notifier: OutdatedCliNotifier; versionProvider: FakeVersionProvider; surface: FakeSurface; @@ -75,12 +75,28 @@ suite('outdatedCliNotifier', () => { const versionProvider = new FakeVersionProvider(); const surface = new FakeSurface(); return { - notifier: new OutdatedCliNotifier(versionProvider, surface, now), + notifier: new OutdatedCliNotifier(versionProvider, surface, now, globalState), versionProvider, surface, }; } + function createMemento(): vscode.Memento { + const values = new Map(); + return { + keys: () => [...values.keys()], + get: (key: string, defaultValue?: T): T | undefined => + values.has(key) ? values.get(key) as T : defaultValue, + update: async (key: string, value: unknown): Promise => { + if (value === undefined) { + values.delete(key); + } else { + values.set(key, value); + } + }, + }; + } + async function waitFor(predicate: () => boolean, message: string): Promise { for (let attempt = 0; attempt < 100; attempt++) { if (predicate()) { @@ -112,7 +128,10 @@ suite('outdatedCliNotifier', () => { assert.strictEqual( surface.warnings[0].message, 'Aspire CLI 13.5.0 at /workspace/a/.aspire/bin/aspire has a newer version available for its current channel: 13.6.0.'); - assert.deepStrictEqual(surface.warnings[0].actions, [strings.updateAspireCliAction]); + assert.deepStrictEqual(surface.warnings[0].actions, [ + strings.updateAspireCliAction, + strings.dontShowAgainLabel, + ]); assert.deepStrictEqual(surface.commands, [{ command: 'aspire-vscode.updateSelf', args: [target, '/workspace/a/.aspire/bin/aspire'], @@ -120,6 +139,74 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); + test("Don't Show Again persists for the exact CLI path and version", async () => { + const globalState = createMemento(); + const first = createNotifier(Date.now, globalState); + first.surface.selection = strings.dontShowAgainLabel; + + await first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual(first.surface.warnings.length, 1); + assert.deepStrictEqual(first.surface.commands, []); + first.notifier.dispose(); + + const second = createNotifier(Date.now, globalState); + await second.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.deepStrictEqual(second.surface.warnings, []); + assert.strictEqual(second.versionProvider.identityCalls.length, 1); + assert.deepStrictEqual(second.versionProvider.recommendationCalls, []); + second.notifier.dispose(); + + const third = createNotifier(Date.now, globalState); + third.versionProvider.identity = { + cliPath: '/cli/aspire', + version: '13.5.1', + }; + third.versionProvider.recommendation = { + status: 'available', + currentVersion: '13.5.1', + version: '13.6.0', + }; + await third.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual(third.surface.warnings.length, 1); + third.notifier.dispose(); + }); + + test("Don't Show Again on a stale warning does not suppress the replacement version", async () => { + let now = 0; + const globalState = createMemento(); + const { notifier, versionProvider, surface } = createNotifier(() => now, globalState); + let resolveOldSelection!: (selection: string | undefined) => void; + surface.selectionPromise = new Promise(resolve => resolveOldSelection = resolve); + + const oldWarning = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await waitFor(() => surface.warnings.length === 1, 'Expected the old CLI warning to open.'); + + now = 5 * 60 * 1_000; + versionProvider.identity = { + cliPath: '/cli/aspire', + version: '13.5.1', + }; + versionProvider.recommendation = { + status: 'available', + currentVersion: '13.5.1', + version: '13.6.0', + }; + surface.selectionPromise = undefined; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + resolveOldSelection(strings.dontShowAgainLabel); + await oldWarning; + + now += 6 * 60 * 60 * 1_000; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual(versionProvider.recommendationCalls.length, 3); + notifier.dispose(); + }); + test('warns when stable 13.4.0 is behind stable 15.3.2', async () => { const { notifier, versionProvider, surface } = createNotifier(); versionProvider.identity = { diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 6f2716afc4c..7e9be57b305 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -17,6 +17,8 @@ const versionFailureRetryMs = 60 * 1_000; const completedUpdateRefreshIntervalMs = 6 * 60 * 60 * 1_000; const unavailableRetryBaseMs = 60 * 1_000; const unavailableRetryMaximumMs = 30 * 60 * 1_000; +const persistedSuppressionKey = 'outdatedCliNotification.suppressedCliVersions'; +const maximumPersistedSuppressions = 100; type CliVersionProvider = Pick; @@ -28,7 +30,7 @@ export interface OutdatedCliNotificationSurface { interface CliCheckState { identity: CliIdentityInfo | undefined; versionValidUntil: number; - updateStatus: 'complete' | 'ineligible' | 'unavailable' | undefined; + updateStatus: 'complete' | 'ineligible' | 'suppressed' | 'unavailable' | undefined; updateValidUntil: number; failureCount: number; } @@ -51,6 +53,7 @@ const defaultSurface: OutdatedCliNotificationSurface = { export class OutdatedCliNotifier implements vscode.Disposable { private readonly _stateByCliPath = new Map(); private readonly _notifiedCliVersions = new Set(); + private readonly _persistentlySuppressedCliVersions: Set; private readonly _inFlightByCliPath = new Map>(); private readonly _cancellationSource = new vscode.CancellationTokenSource(); private readonly _versionLimiter = new AsyncLimiter(4); @@ -61,7 +64,13 @@ export class OutdatedCliNotifier implements vscode.Disposable { private readonly _versionProvider: CliVersionProvider, private readonly _surface: OutdatedCliNotificationSurface = defaultSurface, private readonly _now: () => number = Date.now, + private readonly _globalState?: vscode.Memento, ) { + const persistedSuppressions = _globalState?.get(persistedSuppressionKey); + this._persistentlySuppressedCliVersions = new Set( + Array.isArray(persistedSuppressions) + ? persistedSuppressions.filter((value): value is string => typeof value === 'string') + : []); } async notifyIfOutdated(target: CliPathResolutionTarget, cliPath: string): Promise { @@ -93,8 +102,9 @@ export class OutdatedCliNotifier implements vscode.Disposable { return; } - const notificationKey = `${getComparisonKey(path.normalize(notification.cli.cliPath))}\u0000${notification.cli.version}`; - if (this._notifiedCliVersions.has(notificationKey)) { + const notificationKey = getNotificationKey(notification.cli.cliPath, notification.cli.version); + if (this._notifiedCliVersions.has(notificationKey) || + this._persistentlySuppressedCliVersions.has(notificationKey)) { return; } @@ -104,8 +114,16 @@ export class OutdatedCliNotifier implements vscode.Disposable { notification.cli.version, notification.cli.cliPath, notification.recommendedVersion), - strings.updateAspireCliAction); - if (this._disposed || selection !== strings.updateAspireCliAction) { + strings.updateAspireCliAction, + strings.dontShowAgainLabel); + if (this._disposed) { + return; + } + if (selection === strings.dontShowAgainLabel) { + await this._suppressNotification(notificationKey, notification.cli.cliPath); + return; + } + if (selection !== strings.updateAspireCliAction) { return; } @@ -174,8 +192,17 @@ export class OutdatedCliNotifier implements vscode.Disposable { }; this._stateByCliPath.set(cliPathKey, state); + const notificationKey = getNotificationKey(identity.cliPath, identity.version); + if (this._persistentlySuppressedCliVersions.has(notificationKey)) { + state.updateStatus = 'suppressed'; + state.updateValidUntil = Number.POSITIVE_INFINITY; + state.failureCount = 0; + return undefined; + } + if (!identityChanged && (state.updateStatus === 'ineligible' || + state.updateStatus === 'suppressed' || (state.updateStatus !== undefined && state.updateValidUntil > now))) { return undefined; } @@ -238,6 +265,31 @@ export class OutdatedCliNotifier implements vscode.Disposable { unavailableRetryMaximumMs); } + private async _suppressNotification(notificationKey: string, cliPath: string): Promise { + this._persistentlySuppressedCliVersions.delete(notificationKey); + this._persistentlySuppressedCliVersions.add(notificationKey); + while (this._persistentlySuppressedCliVersions.size > maximumPersistedSuppressions) { + const oldest = this._persistentlySuppressedCliVersions.values().next().value; + if (oldest === undefined) { + break; + } + this._persistentlySuppressedCliVersions.delete(oldest); + } + + const cliPathKey = getComparisonKey(path.normalize(cliPath)); + const state = this._stateByCliPath.get(cliPathKey); + if (state?.identity && + getNotificationKey(state.identity.cliPath, state.identity.version) === notificationKey) { + state.updateStatus = 'suppressed'; + state.updateValidUntil = Number.POSITIVE_INFINITY; + state.failureCount = 0; + } + + await this._globalState?.update( + persistedSuppressionKey, + [...this._persistentlySuppressedCliVersions]); + } + dispose(): void { if (this._disposed) { return; @@ -253,6 +305,10 @@ export class OutdatedCliNotifier implements vscode.Disposable { } } +function getNotificationKey(cliPath: string, version: string): string { + return `${getComparisonKey(path.normalize(cliPath))}\u0000${version}`; +} + function areCliIdentitiesEqual(left: CliIdentityInfo | undefined, right: CliIdentityInfo): boolean { return left?.version === right.version && left.identityChannelOverride === right.identityChannelOverride; From f06937b68eb90d4576a6661ec139239ede4b2db3 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 17:44:13 -0700 Subject: [PATCH 15/31] Add persistent outdated CLI suppression Persist Don't Show Again per CLI path and version, merge suppressions across windows, self-stop inactive heartbeats, and cap failed Doctor retries per identity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- .../src/test/outdatedCliNotifier.test.ts | 76 +++++++++++++++++-- extension/src/utils/cliOperationResolution.ts | 15 +++- extension/src/utils/configInfoProvider.ts | 3 + extension/src/utils/outdatedCliNotifier.ts | 29 +++++-- 4 files changed, 109 insertions(+), 14 deletions(-) diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index 13ec2094a54..ddc02c0eda6 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -174,6 +174,35 @@ suite('outdatedCliNotifier', () => { third.notifier.dispose(); }); + test("Don't Show Again merges suppressions from concurrently open windows", async () => { + const globalState = createMemento(); + const first = createNotifier(Date.now, globalState); + const second = createNotifier(Date.now, globalState); + first.surface.selection = strings.dontShowAgainLabel; + second.surface.selection = strings.dontShowAgainLabel; + second.versionProvider.identity = { + cliPath: '/other/aspire', + version: '13.5.0', + }; + + await first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await second.notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire'); + first.notifier.dispose(); + second.notifier.dispose(); + + const third = createNotifier(Date.now, globalState); + await third.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + third.versionProvider.identity = { + cliPath: '/other/aspire', + version: '13.5.0', + }; + await third.notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire'); + + assert.deepStrictEqual(third.surface.warnings, []); + assert.deepStrictEqual(third.versionProvider.recommendationCalls, []); + third.notifier.dispose(); + }); + test("Don't Show Again on a stale warning does not suppress the replacement version", async () => { let now = 0; const globalState = createMemento(); @@ -298,20 +327,57 @@ suite('outdatedCliNotifier', () => { } }); - test('samples version independently while unavailable doctor checks back off', async () => { + test('active-operation heartbeat stops itself when the operation becomes inactive', async () => { + const clock = sinon.useFakeTimers(); + let active = true; + const resolutions: string[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); + const heartbeat = startCliOperationResolutionHeartbeat( + windowCliPathTarget, + '/cli/aspire', + () => active); + + try { + clock.tick(5 * 60 * 1_000); + assert.deepStrictEqual(resolutions, ['/cli/aspire']); + + active = false; + clock.tick(5 * 60 * 1_000); + assert.deepStrictEqual(resolutions, ['/cli/aspire']); + assert.strictEqual(clock.countTimers(), 0); + } + finally { + heartbeat.dispose(); + subscription.dispose(); + clock.restore(); + } + }); + + test('samples version independently and caps unavailable doctor attempts per identity', async () => { let now = 0; const { notifier, versionProvider } = createNotifier(() => now); versionProvider.recommendation = { status: 'unavailable' }; await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - for (const minute of [5, 10, 15, 20]) { + for (const minute of [5, 10, 15, 20, 25]) { now = minute * 60 * 1_000; await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); } - assert.strictEqual(versionProvider.identityCalls.length, 5); - // Retries at 0, 5, 10 and 15 minutes establish 1/2/4/8-minute backoff. The 20-minute - // version sample remains independent but does not run doctor before minute 23. + assert.strictEqual(versionProvider.identityCalls.length, 6); + assert.strictEqual(versionProvider.recommendationCalls.length, 3); + + now = 30 * 60 * 1_000; + versionProvider.identity = { + cliPath: '/cli/aspire', + version: '13.5.1', + }; + versionProvider.recommendation = { + status: 'none', + currentVersion: '13.5.1', + }; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + assert.strictEqual(versionProvider.recommendationCalls.length, 4); notifier.dispose(); }); diff --git a/extension/src/utils/cliOperationResolution.ts b/extension/src/utils/cliOperationResolution.ts index dc84d4ff889..96a04d9110a 100644 --- a/extension/src/utils/cliOperationResolution.ts +++ b/extension/src/utils/cliOperationResolution.ts @@ -28,10 +28,19 @@ export function startCliOperationResolutionHeartbeat( cliPath: string, isActive: () => boolean, ): vscode.Disposable { + let stopped = false; + const stop = () => { + if (!stopped) { + stopped = true; + clearInterval(timer); + } + }; const timer = setInterval(() => { - if (isActive()) { - reportCliResolvedForOperation(target, cliPath); + if (!isActive()) { + stop(); + return; } + reportCliResolvedForOperation(target, cliPath); }, cliOperationResolutionRefreshIntervalMs); - return new vscode.Disposable(() => clearInterval(timer)); + return new vscode.Disposable(stop); } diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index 858877dd000..51d3546fba4 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -13,6 +13,9 @@ import { nonInteractiveCliEnvironment } from './environment'; const configInfoTimeoutMs = 30_000; const cliVersionProbeTimeoutMs = 30_000; +// The only available structured update status is part of `aspire doctor`, whose complete +// environment-check budget is two minutes. Keep enough time for that transport; a future narrow +// update-status command should use a substantially smaller bound. const cliUpdateProbeTimeoutMs = 130_000; const maxCliVersionOutputLength = 128; const maxCliUpdateOutputLength = 1024 * 1024; diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 7e9be57b305..bcb524e662d 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -17,6 +17,7 @@ const versionFailureRetryMs = 60 * 1_000; const completedUpdateRefreshIntervalMs = 6 * 60 * 60 * 1_000; const unavailableRetryBaseMs = 60 * 1_000; const unavailableRetryMaximumMs = 30 * 60 * 1_000; +const maximumUnavailableAttemptsPerIdentity = 3; const persistedSuppressionKey = 'outdatedCliNotification.suppressedCliVersions'; const maximumPersistedSuppressions = 100; @@ -66,11 +67,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { private readonly _now: () => number = Date.now, private readonly _globalState?: vscode.Memento, ) { - const persistedSuppressions = _globalState?.get(persistedSuppressionKey); - this._persistentlySuppressedCliVersions = new Set( - Array.isArray(persistedSuppressions) - ? persistedSuppressions.filter((value): value is string => typeof value === 'string') - : []); + this._persistentlySuppressedCliVersions = new Set(readPersistedSuppressions(_globalState)); } async notifyIfOutdated(target: CliPathResolutionTarget, cliPath: string): Promise { @@ -208,7 +205,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { } // Do not queue paths behind a potentially slow doctor. The skipped path keeps its - // five-minute version clock and can try again after the active doctor completes. + // five-minute version clock and can try again on its next active-operation heartbeat. if (this._doctorActive) { return undefined; } @@ -260,12 +257,25 @@ export class OutdatedCliNotifier implements vscode.Disposable { private _recordUnavailable(state: CliCheckState): void { state.failureCount = state.updateStatus === 'unavailable' ? state.failureCount + 1 : 1; state.updateStatus = 'unavailable'; + if (state.failureCount >= maximumUnavailableAttemptsPerIdentity) { + // Doctor runs the full environment-check battery. Stop retrying an unchanged identity + // for this session after a few silent failures; five-minute version sampling continues, + // so replacing the CLI resets the state and permits a fresh update check. + state.updateValidUntil = Number.POSITIVE_INFINITY; + return; + } state.updateValidUntil = this._now() + Math.min( unavailableRetryBaseMs * 2 ** (state.failureCount - 1), unavailableRetryMaximumMs); } private async _suppressNotification(notificationKey: string, cliPath: string): Promise { + // Each VS Code window owns a notifier, while globalState is shared by the profile. Merge + // again immediately before writing so suppressions chosen in another open window are not + // lost to a stale constructor-time snapshot. + for (const persistedSuppression of readPersistedSuppressions(this._globalState)) { + this._persistentlySuppressedCliVersions.add(persistedSuppression); + } this._persistentlySuppressedCliVersions.delete(notificationKey); this._persistentlySuppressedCliVersions.add(notificationKey); while (this._persistentlySuppressedCliVersions.size > maximumPersistedSuppressions) { @@ -309,6 +319,13 @@ function getNotificationKey(cliPath: string, version: string): string { return `${getComparisonKey(path.normalize(cliPath))}\u0000${version}`; } +function readPersistedSuppressions(globalState: vscode.Memento | undefined): string[] { + const persistedSuppressions = globalState?.get(persistedSuppressionKey); + return Array.isArray(persistedSuppressions) + ? persistedSuppressions.filter((value): value is string => typeof value === 'string') + : []; +} + function areCliIdentitiesEqual(left: CliIdentityInfo | undefined, right: CliIdentityInfo): boolean { return left?.version === right.version && left.identityChannelOverride === right.identityChannelOverride; From 35628604943f8bb4211d066b127c4839fe5de345 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 18:44:03 -0700 Subject: [PATCH 16/31] Harden outdated CLI notification checks Serialize distinct Doctor probes, persist suppression keys without cross-window overwrite races, and avoid redundant Doctor retries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/test/configInfoProvider.test.ts | 40 +++++- .../src/test/outdatedCliNotifier.test.ts | 72 ++++++++--- extension/src/utils/configInfoProvider.ts | 43 +++++-- extension/src/utils/outdatedCliNotifier.ts | 120 ++++++++++++------ 4 files changed, 198 insertions(+), 77 deletions(-) diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts index c6774585310..2cfdc24b241 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -305,16 +305,16 @@ suite('configInfoProvider tests', () => { assert.deepStrictEqual(parseCliUpdateRecommendationOutput( createDoctorVersionOutput('13.6.0-preview.2', '13.7.0-preview.1', undefined, 'daily')), { status: 'available', currentVersion: '13.6.0-preview.2', version: '13.7.0-preview.1' }); - // Doctor reports only one stable-first recommendation. Ignore that cross-lane result for a - // prerelease install even though a simultaneously newer prerelease is not observable. + // Doctor reports only one stable-first recommendation. Mark that cross-lane result + // ineligible because an unchanged prerelease identity cannot make it actionable. assert.deepStrictEqual(parseCliUpdateRecommendationOutput( createDoctorVersionOutput('13.6.0-preview.2', '13.6.0', undefined, 'daily')), - { status: 'none', currentVersion: '13.6.0-preview.2' }); + { status: 'ineligible', currentVersion: '13.6.0-preview.2' }); // The CLI's stable update rule cannot produce this payload, but reject it defensively so a // stable installation is never nudged onto a prerelease channel. assert.deepStrictEqual(parseCliUpdateRecommendationOutput( createDoctorVersionOutput('13.6.0', '13.7.0-preview.1')), - { status: 'none', currentVersion: '13.6.0' }); + { status: 'ineligible', currentVersion: '13.6.0' }); assert.deepStrictEqual(parseCliUpdateRecommendationOutput( createDoctorVersionOutput('13.6.0')), { status: 'none', currentVersion: '13.6.0' }); @@ -552,9 +552,15 @@ suite('configInfoProvider tests', () => { assert.strictEqual(command, '/exact/aspire'); assert.deepStrictEqual(args, ['doctor', '--format', 'json', '--nologo']); assert.deepStrictEqual(options?.env, [{ name: 'ASPIRE_NON_INTERACTIVE', value: 'true' }]); - options?.stdoutCallback?.(createDoctorVersionOutput('13.5.0', '13.6.0')); + const output = JSON.parse(createDoctorVersionOutput('13.5.0', '13.6.0')); + output.checks.push({ + name: 'unrelated-check', + details: 'A nested command used --nologo.', + }); + options?.stdoutCallback?.(JSON.stringify(output)); // `aspire doctor` exits nonzero when an unrelated prerequisite check fails, but its - // structured CLI update metadata is still valid. + // structured CLI update metadata is still valid. Text from another check must not be + // mistaken for the root command rejecting --nologo. options?.exitCallback?.(1); return {} as ChildProcessWithoutNullStreams; }); @@ -566,6 +572,28 @@ suite('configInfoProvider tests', () => { assert.strictEqual(spawnStub.callCount, 1); }); + test('getCliUpdateRecommendation does not retry a structured unavailable result containing nologo', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { + assert.strictEqual(command, '/exact/aspire'); + assert.deepStrictEqual(args, ['doctor', '--format', 'json', '--nologo']); + const output = JSON.parse(createDoctorVersionOutput('13.5.0', undefined, 'offline')); + output.checks[0].details = 'A nested command used --nologo.'; + options?.stdoutCallback?.(JSON.stringify(output)); + options?.exitCallback?.(0); + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + assert.deepStrictEqual( + await provider.getCliUpdateRecommendation({ cliPath: '/exact/aspire' }), + { status: 'unavailable' }); + assert.strictEqual(spawnStub.callCount, 1); + }); + test('getCliUpdateRecommendation retries without nologo and keeps unavailable checks silent', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => '/unused/aspire', diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index ddc02c0eda6..3e557bf9238 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -81,8 +81,7 @@ suite('outdatedCliNotifier', () => { }; } - function createMemento(): vscode.Memento { - const values = new Map(); + function createMemento(values = new Map()): vscode.Memento { return { keys: () => [...values.keys()], get: (key: string, defaultValue?: T): T | undefined => @@ -174,8 +173,39 @@ suite('outdatedCliNotifier', () => { third.notifier.dispose(); }); - test("Don't Show Again merges suppressions from concurrently open windows", async () => { - const globalState = createMemento(); + test("Don't Show Again evicts the oldest persisted suppression", async () => { + let now = 0; + const values = new Map(); + const globalState = createMemento(values); + const { notifier, versionProvider, surface } = createNotifier(() => now, globalState); + surface.selection = strings.dontShowAgainLabel; + + for (let index = 0; index <= 100; index++) { + const cliPath = `/cli/${index}/aspire`; + versionProvider.identity = { + cliPath, + version: '13.5.0', + }; + await notifier.notifyIfOutdated(windowCliPathTarget, cliPath); + now++; + } + + assert.strictEqual(values.size, 100); + assert.strictEqual([...values.values()].includes(0), false); + assert.strictEqual([...values.values()].includes(100), true); + notifier.dispose(); + }); + + test("Don't Show Again persists concurrent suppressions independently across windows", async () => { + const values = new Map(); + const pendingUpdates: Array<{ key: string; value: unknown; complete: () => void }> = []; + const globalState: vscode.Memento = { + keys: () => [...values.keys()], + get: (key: string, defaultValue?: T): T | undefined => + values.has(key) ? values.get(key) as T : defaultValue, + update: (key: string, value: unknown): Thenable => + new Promise(resolve => pendingUpdates.push({ key, value, complete: resolve })), + }; const first = createNotifier(Date.now, globalState); const second = createNotifier(Date.now, globalState); first.surface.selection = strings.dontShowAgainLabel; @@ -185,8 +215,20 @@ suite('outdatedCliNotifier', () => { version: '13.5.0', }; - await first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - await second.notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire'); + const suppressions = [ + first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'), + second.notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire'), + ]; + await waitFor(() => pendingUpdates.length === 2, 'Expected independent suppression writes.'); + for (const update of pendingUpdates.splice(0)) { + if (update.value === undefined) { + values.delete(update.key); + } else { + values.set(update.key, update.value); + } + update.complete(); + } + await Promise.all(suppressions); first.notifier.dispose(); second.notifier.dispose(); @@ -442,8 +484,7 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); - test('coalesces same-path checks and does not queue behind the active doctor', async () => { - let now = 0; + test('coalesces same-path checks and serializes distinct doctors', async () => { const versionProvider = new FakeVersionProvider(); versionProvider.getCliIdentity = async options => { versionProvider.identityCalls.push(options); @@ -469,27 +510,18 @@ suite('outdatedCliNotifier', () => { }); }); }; - const notifier = new OutdatedCliNotifier(versionProvider, new FakeSurface(), () => now); + const notifier = new OutdatedCliNotifier(versionProvider, new FakeSurface()); const shared = Array.from({ length: 10 }, () => notifier.notifyIfOutdated(windowCliPathTarget, '/shared/aspire')); const distinct = notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire'); await waitFor(() => releaseDoctors.length === 1, 'Expected first serialized doctor.'); releaseDoctors.shift()?.(); + await waitFor(() => releaseDoctors.length === 1, 'Expected second serialized doctor.'); + releaseDoctors.shift()?.(); await Promise.all([...shared, distinct]); assert.strictEqual(versionProvider.identityCalls.length, 2); - assert.strictEqual(versionProvider.recommendationCalls.length, 1); - assert.strictEqual(maximumActiveDoctors, 1); - - const checkedPath = versionProvider.recommendationCalls[0]?.cliPath; - const skippedPath = checkedPath === '/shared/aspire' ? '/other/aspire' : '/shared/aspire'; - now = 5 * 60 * 1_000; - const retry = notifier.notifyIfOutdated(windowCliPathTarget, skippedPath); - await waitFor(() => releaseDoctors.length === 1, 'Expected skipped path to retry doctor.'); - releaseDoctors.shift()?.(); - await retry; - assert.strictEqual(versionProvider.recommendationCalls.length, 2); assert.strictEqual(maximumActiveDoctors, 1); notifier.dispose(); diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index 51d3546fba4..7afceada29a 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -640,22 +640,30 @@ export class ConfigInfoProvider { if (settled || termination) { return; } - if (allowNoLogoRetry && isNoLogoUnsupportedOutput(args, output, stderr)) { - runDoctor(removeRootNoLogoOption(args), false); - return; - } if (outputTooLong) { settle({ status: 'unavailable' }); return; } try { - settle(parseCliUpdateRecommendationOutput(output, identityChannelOverride)); + const recommendation = parseCliUpdateRecommendationOutput(output, identityChannelOverride); + if (recommendation.status !== 'unavailable' || + isStructuredDoctorOutput(output) || + !allowNoLogoRetry || + !isNoLogoUnsupportedOutput(args, output, stderr)) { + settle(recommendation); + return; + } } catch (error) { - extensionLogOutputChannel.warn(`Unable to parse Aspire CLI update status: ${String(error)}`); - settle({ status: 'unavailable' }); + if (!allowNoLogoRetry || !isNoLogoUnsupportedOutput(args, output, stderr)) { + extensionLogOutputChannel.warn(`Unable to parse Aspire CLI update status: ${String(error)}`); + settle({ status: 'unavailable' }); + return; + } } + + runDoctor(removeRootNoLogoOption(args), false); }, errorCallback: reportUnavailable, }); @@ -1067,9 +1075,24 @@ export function parseCliUpdateRecommendationOutput( } const expectedRecommendationChannel = identityChannel === 'stable' ? 'stable' : 'prerelease'; - return latestVersionChannel === expectedRecommendationChannel - ? { status: 'available', currentVersion: currentVersion.value, version: latestVersion.value } - : { status: 'none', currentVersion: currentVersion.value }; + if (latestVersionChannel !== expectedRecommendationChannel) { + // Doctor returns its stable recommendation before considering prerelease updates. For an + // unchanged identity that cross-lane result cannot become actionable, so classify it as + // ineligible and avoid rerunning the full Doctor battery on the normal refresh interval. + return { status: 'ineligible', currentVersion: currentVersion.value }; + } + + return { status: 'available', currentVersion: currentVersion.value, version: latestVersion.value }; +} + +function isStructuredDoctorOutput(output: string): boolean { + try { + const response = JSON.parse(output.trim()) as { checks?: unknown }; + return Array.isArray(response.checks); + } + catch { + return false; + } } function normalizePublishedCliChannel(value: unknown): 'stable' | 'daily' | 'staging' | undefined { diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index bcb524e662d..473343d4bfd 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -18,7 +18,7 @@ const completedUpdateRefreshIntervalMs = 6 * 60 * 60 * 1_000; const unavailableRetryBaseMs = 60 * 1_000; const unavailableRetryMaximumMs = 30 * 60 * 1_000; const maximumUnavailableAttemptsPerIdentity = 3; -const persistedSuppressionKey = 'outdatedCliNotification.suppressedCliVersions'; +const persistedSuppressionKeyPrefix = 'outdatedCliNotification.suppressedCliVersion.'; const maximumPersistedSuppressions = 100; type CliVersionProvider = Pick; @@ -42,6 +42,12 @@ interface PendingNotification { recommendedVersion: string; } +interface PersistedSuppression { + notificationKey: string; + storageKey: string; + suppressedAt: number; +} + const defaultSurface: OutdatedCliNotificationSurface = { showWarning: (message, ...actions) => vscode.window.showWarningMessage(message, ...actions), executeCommand: (command, ...args) => vscode.commands.executeCommand(command, ...args), @@ -58,7 +64,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { private readonly _inFlightByCliPath = new Map>(); private readonly _cancellationSource = new vscode.CancellationTokenSource(); private readonly _versionLimiter = new AsyncLimiter(4); - private _doctorActive = false; + private readonly _doctorLimiter = new AsyncLimiter(1); private _disposed = false; constructor( @@ -67,7 +73,8 @@ export class OutdatedCliNotifier implements vscode.Disposable { private readonly _now: () => number = Date.now, private readonly _globalState?: vscode.Memento, ) { - this._persistentlySuppressedCliVersions = new Set(readPersistedSuppressions(_globalState)); + this._persistentlySuppressedCliVersions = new Set( + readPersistedSuppressions(_globalState).map(suppression => suppression.notificationKey)); } async notifyIfOutdated(target: CliPathResolutionTarget, cliPath: string): Promise { @@ -204,27 +211,18 @@ export class OutdatedCliNotifier implements vscode.Disposable { return undefined; } - // Do not queue paths behind a potentially slow doctor. The skipped path keeps its - // five-minute version clock and can try again on its next active-operation heartbeat. - if (this._doctorActive) { - return undefined; - } - - this._doctorActive = true; - let recommendationProbe: LimiterResult; - try { - recommendationProbe = await this._versionLimiter.run(() => + const serializedRecommendationProbe = await this._doctorLimiter.run(() => + this._versionLimiter.run(() => this._versionProvider.getCliUpdateRecommendation({ target, cliPath, identityChannelOverride: identity.identityChannelOverride, cancellationToken: this._cancellationSource.token, - })); - } - finally { - this._doctorActive = false; - } - if (!recommendationProbe.executed || this._disposed) { + }))); + const recommendationProbe = serializedRecommendationProbe.executed + ? serializedRecommendationProbe.value + : undefined; + if (!recommendationProbe?.executed || this._disposed) { return undefined; } @@ -270,21 +268,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { } private async _suppressNotification(notificationKey: string, cliPath: string): Promise { - // Each VS Code window owns a notifier, while globalState is shared by the profile. Merge - // again immediately before writing so suppressions chosen in another open window are not - // lost to a stale constructor-time snapshot. - for (const persistedSuppression of readPersistedSuppressions(this._globalState)) { - this._persistentlySuppressedCliVersions.add(persistedSuppression); - } - this._persistentlySuppressedCliVersions.delete(notificationKey); this._persistentlySuppressedCliVersions.add(notificationKey); - while (this._persistentlySuppressedCliVersions.size > maximumPersistedSuppressions) { - const oldest = this._persistentlySuppressedCliVersions.values().next().value; - if (oldest === undefined) { - break; - } - this._persistentlySuppressedCliVersions.delete(oldest); - } const cliPathKey = getComparisonKey(path.normalize(cliPath)); const state = this._stateByCliPath.get(cliPathKey); @@ -295,9 +279,34 @@ export class OutdatedCliNotifier implements vscode.Disposable { state.failureCount = 0; } - await this._globalState?.update( - persistedSuppressionKey, - [...this._persistentlySuppressedCliVersions]); + if (!this._globalState) { + return; + } + + // Each VS Code window owns a notifier, while globalState is shared by the profile. Store + // suppressions under independent keys so concurrent choices cannot overwrite one another. + const suppressedAt = this._now(); + await this._globalState.update(getPersistedSuppressionStorageKey(notificationKey), suppressedAt); + const persistedSuppressions = readPersistedSuppressions(this._globalState); + for (const persistedSuppression of persistedSuppressions) { + this._persistentlySuppressedCliVersions.add(persistedSuppression.notificationKey); + } + + const excessCount = persistedSuppressions.length - maximumPersistedSuppressions; + if (excessCount <= 0) { + return; + } + + const suppressionsToRemove = persistedSuppressions + .filter(suppression => suppression.notificationKey !== notificationKey) + .sort((left, right) => + left.suppressedAt - right.suppressedAt || + left.storageKey.localeCompare(right.storageKey)) + .slice(0, excessCount); + for (const suppression of suppressionsToRemove) { + await this._globalState.update(suppression.storageKey, undefined); + this._persistentlySuppressedCliVersions.delete(suppression.notificationKey); + } } dispose(): void { @@ -308,6 +317,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { this._disposed = true; this._cancellationSource.cancel(); this._cancellationSource.dispose(); + this._doctorLimiter.dispose(); this._versionLimiter.dispose(); this._inFlightByCliPath.clear(); this._stateByCliPath.clear(); @@ -319,11 +329,39 @@ function getNotificationKey(cliPath: string, version: string): string { return `${getComparisonKey(path.normalize(cliPath))}\u0000${version}`; } -function readPersistedSuppressions(globalState: vscode.Memento | undefined): string[] { - const persistedSuppressions = globalState?.get(persistedSuppressionKey); - return Array.isArray(persistedSuppressions) - ? persistedSuppressions.filter((value): value is string => typeof value === 'string') - : []; +function getPersistedSuppressionStorageKey(notificationKey: string): string { + return `${persistedSuppressionKeyPrefix}${encodeURIComponent(notificationKey)}`; +} + +function readPersistedSuppressions(globalState: vscode.Memento | undefined): PersistedSuppression[] { + if (!globalState) { + return []; + } + + const suppressions: PersistedSuppression[] = []; + for (const storageKey of globalState.keys()) { + if (!storageKey.startsWith(persistedSuppressionKeyPrefix)) { + continue; + } + + const suppressedAt = globalState.get(storageKey); + if (typeof suppressedAt !== 'number' || !Number.isFinite(suppressedAt)) { + continue; + } + + try { + suppressions.push({ + notificationKey: decodeURIComponent(storageKey.slice(persistedSuppressionKeyPrefix.length)), + storageKey, + suppressedAt, + }); + } + catch { + // Ignore unrelated or corrupted global state keys rather than blocking extension startup. + } + } + + return suppressions; } function areCliIdentitiesEqual(left: CliIdentityInfo | undefined, right: CliIdentityInfo): boolean { From f0580a924168a68ce9ea4b5056c84cda4dcc1665 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 19:24:22 -0700 Subject: [PATCH 17/31] Fix multi-root CLI update state Scope recommendations to the captured Doctor context and make suppression compaction safe across concurrent VS Code windows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/test/configInfoProvider.test.ts | 6 +- .../src/test/outdatedCliNotifier.test.ts | 178 +++++++++++++++++- extension/src/utils/configInfoProvider.ts | 16 +- extension/src/utils/outdatedCliNotifier.ts | 136 ++++++++----- 4 files changed, 282 insertions(+), 54 deletions(-) diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts index 2cfdc24b241..19da2260281 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -552,6 +552,7 @@ suite('configInfoProvider tests', () => { assert.strictEqual(command, '/exact/aspire'); assert.deepStrictEqual(args, ['doctor', '--format', 'json', '--nologo']); assert.deepStrictEqual(options?.env, [{ name: 'ASPIRE_NON_INTERACTIVE', value: 'true' }]); + assert.strictEqual(options?.workingDirectory, '/captured/workspace'); const output = JSON.parse(createDoctorVersionOutput('13.5.0', '13.6.0')); output.checks.push({ name: 'unrelated-check', @@ -567,7 +568,10 @@ suite('configInfoProvider tests', () => { const provider = new ConfigInfoProvider(terminalProvider); assert.deepStrictEqual( - await provider.getCliUpdateRecommendation({ cliPath: '/exact/aspire' }), + await provider.getCliUpdateRecommendation({ + cliPath: '/exact/aspire', + workingDirectory: '/captured/workspace', + }), { status: 'available', currentVersion: '13.5.0', version: '13.6.0' }); assert.strictEqual(spawnStub.callCount, 1); }); diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index 3e557bf9238..b88998c8625 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -191,8 +191,9 @@ suite('outdatedCliNotifier', () => { } assert.strictEqual(values.size, 100); - assert.strictEqual([...values.values()].includes(0), false); - assert.strictEqual([...values.values()].includes(100), true); + const persistedValues = [...values.values()] as Array<{ suppressedAt: number }>; + assert.strictEqual(persistedValues.some(value => value.suppressedAt === 0), false); + assert.strictEqual(persistedValues.some(value => value.suppressedAt === 100), true); notifier.dispose(); }); @@ -245,6 +246,97 @@ suite('outdatedCliNotifier', () => { third.notifier.dispose(); }); + test("Don't Show Again compaction cannot delete a concurrent refresh", async () => { + let now = 0; + const values = new Map(); + let blockNextDeletion = false; + let blockedDeletion: { complete: () => void } | undefined; + const globalState: vscode.Memento = { + keys: () => [...values.keys()], + get: (key: string, defaultValue?: T): T | undefined => + values.has(key) ? values.get(key) as T : defaultValue, + update: (key: string, value: unknown): Thenable => { + if (value === undefined && blockNextDeletion) { + blockNextDeletion = false; + return new Promise(resolve => { + blockedDeletion = { + complete: () => { + values.delete(key); + resolve(); + }, + }; + }); + } + + if (value === undefined) { + values.delete(key); + } else { + values.set(key, value); + } + return Promise.resolve(); + }, + }; + + const staleWindow = createNotifier(() => now, globalState); + staleWindow.versionProvider.identity = { + cliPath: '/cli/0/aspire', + version: '13.5.0', + }; + let completeStaleSelection!: (selection: string | undefined) => void; + staleWindow.surface.selectionPromise = new Promise(resolve => completeStaleSelection = resolve); + const staleNotification = staleWindow.notifier.notifyIfOutdated( + windowCliPathTarget, + '/cli/0/aspire'); + await waitFor( + () => staleWindow.surface.warnings.length === 1, + 'Expected stale window warning to open.'); + + const seeder = createNotifier(() => now, globalState); + seeder.surface.selection = strings.dontShowAgainLabel; + for (let index = 0; index < 100; index++) { + const cliPath = `/cli/${index}/aspire`; + seeder.versionProvider.identity = { + cliPath, + version: '13.5.0', + }; + await seeder.notifier.notifyIfOutdated(windowCliPathTarget, cliPath); + now++; + } + seeder.notifier.dispose(); + + blockNextDeletion = true; + const compactor = createNotifier(() => now, globalState); + compactor.surface.selection = strings.dontShowAgainLabel; + compactor.versionProvider.identity = { + cliPath: '/cli/new/aspire', + version: '13.5.0', + }; + const compaction = compactor.notifier.notifyIfOutdated( + windowCliPathTarget, + '/cli/new/aspire'); + await waitFor(() => blockedDeletion !== undefined, 'Expected stale suppression deletion to block.'); + + now++; + completeStaleSelection(strings.dontShowAgainLabel); + await staleNotification; + blockedDeletion?.complete(); + await compaction; + + const freshWindow = createNotifier(() => now, globalState); + freshWindow.versionProvider.identity = { + cliPath: '/cli/0/aspire', + version: '13.5.0', + }; + await freshWindow.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/0/aspire'); + + assert.strictEqual(values.size, 100); + assert.deepStrictEqual(freshWindow.surface.warnings, []); + assert.deepStrictEqual(freshWindow.versionProvider.recommendationCalls, []); + freshWindow.notifier.dispose(); + compactor.notifier.dispose(); + staleWindow.notifier.dispose(); + }); + test("Don't Show Again on a stale warning does not suppress the replacement version", async () => { let now = 0; const globalState = createMemento(); @@ -527,6 +619,88 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); + test('isolates same-path update recommendations by resolution target', async () => { + const folderA: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace/a'), + name: 'a', + index: 0, + }; + const folderB: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace/b'), + name: 'b', + index: 1, + }; + const targetA = workspaceFolderCliPathTarget(folderA); + const targetB = workspaceFolderCliPathTarget(folderB); + const versionProvider = new FakeVersionProvider(); + versionProvider.identity = { + cliPath: '/shared/aspire', + version: '13.5.0', + }; + versionProvider.getCliUpdateRecommendation = async options => { + versionProvider.recommendationCalls.push(options); + return options?.target === targetA + ? { status: 'none', currentVersion: '13.5.0' } + : { status: 'available', currentVersion: '13.5.0', version: '13.6.0' }; + }; + const surface = new FakeSurface(); + const notifier = new OutdatedCliNotifier(versionProvider, surface); + + await notifier.notifyIfOutdated(targetA, '/shared/aspire'); + await notifier.notifyIfOutdated(targetB, '/shared/aspire'); + + assert.deepStrictEqual( + versionProvider.recommendationCalls.map(call => call?.target), + [targetA, targetB]); + assert.strictEqual(surface.warnings.length, 1); + notifier.dispose(); + }); + + test('refreshes the window-scoped recommendation when its Doctor working directory changes', async () => { + const folderA: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace/a'), + name: 'a', + index: 0, + }; + const folderB: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace/b'), + name: 'b', + index: 0, + }; + let workspaceFolders: readonly vscode.WorkspaceFolder[] = []; + const workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').get(() => workspaceFolders); + const versionProvider = new FakeVersionProvider(); + versionProvider.identity = { + cliPath: '/shared/aspire', + version: '13.5.0', + }; + versionProvider.getCliUpdateRecommendation = async options => { + versionProvider.recommendationCalls.push(options); + return options?.workingDirectory === folderB.uri.fsPath + ? { status: 'available', currentVersion: '13.5.0', version: '13.6.0' } + : { status: 'none', currentVersion: '13.5.0' }; + }; + const surface = new FakeSurface(); + const notifier = new OutdatedCliNotifier(versionProvider, surface); + + try { + await notifier.notifyIfOutdated(windowCliPathTarget, '/shared/aspire'); + workspaceFolders = [folderA]; + await notifier.notifyIfOutdated(windowCliPathTarget, '/shared/aspire'); + workspaceFolders = [folderB]; + await notifier.notifyIfOutdated(windowCliPathTarget, '/shared/aspire'); + + assert.deepStrictEqual( + versionProvider.recommendationCalls.map(call => call?.workingDirectory), + [process.cwd(), folderA.uri.fsPath, folderB.uri.fsPath]); + assert.strictEqual(surface.warnings.length, 1); + } + finally { + notifier.dispose(); + workspaceFoldersStub.restore(); + } + }); + test('does not warn when physical and effective versions disagree', async () => { const { notifier, versionProvider, surface } = createNotifier(); versionProvider.identity = { diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index 7afceada29a..3632b220b69 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -77,6 +77,8 @@ export interface CliVersionStatusOptions { export type CliUpdateRecommendationOptions = Omit & { identityChannelOverride?: string; + /** Captured Doctor working directory, when the caller must keep cache and process context aligned. */ + workingDirectory?: string; }; export interface CliVersionInfo { @@ -117,14 +119,15 @@ interface CliVersion { * The CLI discovers `aspire.config.json` by walking up from its working directory, so the folder it * runs in decides which local settings file the answer describes. Window-scoped callers have no * folder of their own and fall back to the first one, which is the best available guess and matches - * how other window-scoped commands behave. + * how other window-scoped commands behave. With no workspace, capture the extension host's current + * directory rather than leaving process launch to resolve a potentially changed workspace later. */ -function resolveConfigInfoWorkingDirectory(target: CliPathResolutionTarget): string | undefined { +export function resolveConfigInfoWorkingDirectory(target: CliPathResolutionTarget): string { if (target.kind === 'workspaceFolder') { return target.workspaceFolder.uri.fsPath; } - return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); } /** @@ -303,9 +306,10 @@ export class ConfigInfoProvider { return { status: 'unavailable' }; } + const workingDirectory = options?.workingDirectory ?? resolveConfigInfoWorkingDirectory(target); return await this._fetchCliUpdateRecommendation( cliPath, - target, + workingDirectory, options?.identityChannelOverride, options?.cancellationToken); } @@ -559,7 +563,7 @@ export class ConfigInfoProvider { private _fetchCliUpdateRecommendation( cliPath: string, - target: CliPathResolutionTarget, + workingDirectory: string, identityChannelOverride: string | undefined, cancellationToken?: vscode.CancellationToken, ): Promise { @@ -621,7 +625,7 @@ export class ConfigInfoProvider { try { childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { createProcessGroup: true, - workingDirectory: resolveConfigInfoWorkingDirectory(target), + workingDirectory, noExtensionVariables: true, env: nonInteractiveCliEnvironment, stdoutCallback: value => { diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 473343d4bfd..889a2069201 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -7,8 +7,9 @@ import { CliVersionInfo, compareCliVersionValues, ConfigInfoProvider, + resolveConfigInfoWorkingDirectory, } from './configInfoProvider'; -import { CliPathResolutionTarget } from './cliPathVariables'; +import { CliPathResolutionTarget, getCliPathTargetKey } from './cliPathVariables'; import { getComparisonKey } from './paths/comparison'; const updateAspireCliCommand = 'aspire-vscode.updateSelf'; @@ -20,6 +21,7 @@ const unavailableRetryMaximumMs = 30 * 60 * 1_000; const maximumUnavailableAttemptsPerIdentity = 3; const persistedSuppressionKeyPrefix = 'outdatedCliNotification.suppressedCliVersion.'; const maximumPersistedSuppressions = 100; +let persistedSuppressionSequence = 0; type CliVersionProvider = Pick; @@ -37,6 +39,7 @@ interface CliCheckState { } interface PendingNotification { + checkKey: string; target: CliPathResolutionTarget; cli: CliIdentityInfo; recommendedVersion: string; @@ -58,10 +61,10 @@ const defaultSurface: OutdatedCliNotificationSurface = { * periodic; the heavyweight doctor adapter is limited to one active probe and cached independently. */ export class OutdatedCliNotifier implements vscode.Disposable { - private readonly _stateByCliPath = new Map(); + private readonly _stateByCheckKey = new Map(); private readonly _notifiedCliVersions = new Set(); private readonly _persistentlySuppressedCliVersions: Set; - private readonly _inFlightByCliPath = new Map>(); + private readonly _inFlightByCheckKey = new Map>(); private readonly _cancellationSource = new vscode.CancellationTokenSource(); private readonly _versionLimiter = new AsyncLimiter(4); private readonly _doctorLimiter = new AsyncLimiter(1); @@ -82,12 +85,13 @@ export class OutdatedCliNotifier implements vscode.Disposable { return; } - const cliPathKey = getComparisonKey(path.normalize(cliPath)); - if ((this._stateByCliPath.get(cliPathKey)?.versionValidUntil ?? 0) > this._now()) { + const workingDirectory = resolveConfigInfoWorkingDirectory(target); + const checkKey = getCliCheckKey(target, cliPath, workingDirectory); + if ((this._stateByCheckKey.get(checkKey)?.versionValidUntil ?? 0) > this._now()) { return; } - const existingProbe = this._inFlightByCliPath.get(cliPathKey); + const existingProbe = this._inFlightByCheckKey.get(checkKey); if (existingProbe) { await existingProbe; return; @@ -95,12 +99,17 @@ export class OutdatedCliNotifier implements vscode.Disposable { const checkStartedAt = this._now(); let probe!: Promise; - probe = this._checkForUpdate(target, cliPathKey, cliPath, checkStartedAt).finally(() => { - if (this._inFlightByCliPath.get(cliPathKey) === probe) { - this._inFlightByCliPath.delete(cliPathKey); + probe = this._checkForUpdate( + target, + checkKey, + cliPath, + workingDirectory, + checkStartedAt).finally(() => { + if (this._inFlightByCheckKey.get(checkKey) === probe) { + this._inFlightByCheckKey.delete(checkKey); } }); - this._inFlightByCliPath.set(cliPathKey, probe); + this._inFlightByCheckKey.set(checkKey, probe); const notification = await probe; if (this._disposed || !notification) { return; @@ -124,7 +133,9 @@ export class OutdatedCliNotifier implements vscode.Disposable { return; } if (selection === strings.dontShowAgainLabel) { - await this._suppressNotification(notificationKey, notification.cli.cliPath); + await this._suppressNotification( + notificationKey, + notification.checkKey); return; } if (selection !== strings.updateAspireCliAction) { @@ -150,8 +161,9 @@ export class OutdatedCliNotifier implements vscode.Disposable { private async _checkForUpdate( target: CliPathResolutionTarget, - cliPathKey: string, + checkKey: string, cliPath: string, + workingDirectory: string, checkStartedAt: number, ): Promise { const versionProbe = await this._versionLimiter.run(() => @@ -165,10 +177,10 @@ export class OutdatedCliNotifier implements vscode.Disposable { } const now = this._now(); - const previous = this._stateByCliPath.get(cliPathKey); + const previous = this._stateByCheckKey.get(checkKey); const identity = versionProbe.value; if (!identity) { - this._stateByCliPath.set(cliPathKey, { + this._stateByCheckKey.set(checkKey, { identity: previous?.identity, versionValidUntil: checkStartedAt + versionFailureRetryMs, updateStatus: previous?.updateStatus, @@ -194,7 +206,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { updateValidUntil: previous?.updateValidUntil ?? 0, failureCount: previous?.failureCount ?? 0, }; - this._stateByCliPath.set(cliPathKey, state); + this._stateByCheckKey.set(checkKey, state); const notificationKey = getNotificationKey(identity.cliPath, identity.version); if (this._persistentlySuppressedCliVersions.has(notificationKey)) { @@ -217,6 +229,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { target, cliPath, identityChannelOverride: identity.identityChannelOverride, + workingDirectory, cancellationToken: this._cancellationSource.token, }))); const recommendationProbe = serializedRecommendationProbe.executed @@ -248,7 +261,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { const comparison = compareCliVersionValues(identity.version, recommendation.version); return comparison !== undefined && comparison < 0 - ? { target, cli: identity, recommendedVersion: recommendation.version } + ? { checkKey, target, cli: identity, recommendedVersion: recommendation.version } : undefined; } @@ -267,11 +280,13 @@ export class OutdatedCliNotifier implements vscode.Disposable { unavailableRetryMaximumMs); } - private async _suppressNotification(notificationKey: string, cliPath: string): Promise { + private async _suppressNotification( + notificationKey: string, + checkKey: string, + ): Promise { this._persistentlySuppressedCliVersions.add(notificationKey); - const cliPathKey = getComparisonKey(path.normalize(cliPath)); - const state = this._stateByCliPath.get(cliPathKey); + const state = this._stateByCheckKey.get(checkKey); if (state?.identity && getNotificationKey(state.identity.cliPath, state.identity.version) === notificationKey) { state.updateStatus = 'suppressed'; @@ -286,26 +301,43 @@ export class OutdatedCliNotifier implements vscode.Disposable { // Each VS Code window owns a notifier, while globalState is shared by the profile. Store // suppressions under independent keys so concurrent choices cannot overwrite one another. const suppressedAt = this._now(); - await this._globalState.update(getPersistedSuppressionStorageKey(notificationKey), suppressedAt); + const persistedSuppression = { + notificationKey, + storageKey: getPersistedSuppressionStorageKey(suppressedAt), + suppressedAt, + }; + await this._globalState.update(persistedSuppression.storageKey, { + notificationKey, + suppressedAt, + }); const persistedSuppressions = readPersistedSuppressions(this._globalState); for (const persistedSuppression of persistedSuppressions) { this._persistentlySuppressedCliVersions.add(persistedSuppression.notificationKey); } - const excessCount = persistedSuppressions.length - maximumPersistedSuppressions; - if (excessCount <= 0) { - return; + const latestSuppressionByNotification = new Map(); + const staleSuppressions: PersistedSuppression[] = []; + for (const suppression of persistedSuppressions) { + const previous = latestSuppressionByNotification.get(suppression.notificationKey); + if (!previous || comparePersistedSuppressions(previous, suppression) < 0) { + if (previous) { + staleSuppressions.push(previous); + } + latestSuppressionByNotification.set(suppression.notificationKey, suppression); + } else { + staleSuppressions.push(suppression); + } } - const suppressionsToRemove = persistedSuppressions + const excessCount = latestSuppressionByNotification.size - maximumPersistedSuppressions; + const cappedSuppressions = [...latestSuppressionByNotification.values()] .filter(suppression => suppression.notificationKey !== notificationKey) - .sort((left, right) => - left.suppressedAt - right.suppressedAt || - left.storageKey.localeCompare(right.storageKey)) - .slice(0, excessCount); - for (const suppression of suppressionsToRemove) { + .sort(comparePersistedSuppressions) + .slice(0, Math.max(0, excessCount)); + for (const suppression of [...staleSuppressions, ...cappedSuppressions]) { + // Storage keys identify immutable generations. Deleting an observed generation cannot + // remove a newer suppression written concurrently by another VS Code window. await this._globalState.update(suppression.storageKey, undefined); - this._persistentlySuppressedCliVersions.delete(suppression.notificationKey); } } @@ -319,8 +351,8 @@ export class OutdatedCliNotifier implements vscode.Disposable { this._cancellationSource.dispose(); this._doctorLimiter.dispose(); this._versionLimiter.dispose(); - this._inFlightByCliPath.clear(); - this._stateByCliPath.clear(); + this._inFlightByCheckKey.clear(); + this._stateByCheckKey.clear(); this._notifiedCliVersions.clear(); } } @@ -329,8 +361,21 @@ function getNotificationKey(cliPath: string, version: string): string { return `${getComparisonKey(path.normalize(cliPath))}\u0000${version}`; } -function getPersistedSuppressionStorageKey(notificationKey: string): string { - return `${persistedSuppressionKeyPrefix}${encodeURIComponent(notificationKey)}`; +function getCliCheckKey( + target: CliPathResolutionTarget, + cliPath: string, + workingDirectory: string, +): string { + return `${getCliPathTargetKey(target)}\u0000${getComparisonKey(path.normalize(cliPath))}\u0000${getComparisonKey(path.normalize(workingDirectory))}`; +} + +function getPersistedSuppressionStorageKey(suppressedAt: number): string { + return `${persistedSuppressionKeyPrefix}${suppressedAt}.${process.pid}.${persistedSuppressionSequence++}`; +} + +function comparePersistedSuppressions(left: PersistedSuppression, right: PersistedSuppression): number { + return left.suppressedAt - right.suppressedAt || + (left.storageKey < right.storageKey ? -1 : left.storageKey > right.storageKey ? 1 : 0); } function readPersistedSuppressions(globalState: vscode.Memento | undefined): PersistedSuppression[] { @@ -344,21 +389,22 @@ function readPersistedSuppressions(globalState: vscode.Memento | undefined): Per continue; } - const suppressedAt = globalState.get(storageKey); - if (typeof suppressedAt !== 'number' || !Number.isFinite(suppressedAt)) { + const value = globalState.get(storageKey); + if (typeof value !== 'object' || value === null) { continue; } - try { - suppressions.push({ - notificationKey: decodeURIComponent(storageKey.slice(persistedSuppressionKeyPrefix.length)), - storageKey, - suppressedAt, - }); - } - catch { - // Ignore unrelated or corrupted global state keys rather than blocking extension startup. + const { notificationKey, suppressedAt } = value as { + notificationKey?: unknown; + suppressedAt?: unknown; + }; + if (typeof notificationKey !== 'string' || + typeof suppressedAt !== 'number' || + !Number.isFinite(suppressedAt)) { + continue; } + + suppressions.push({ notificationKey, storageKey, suppressedAt }); } return suppressions; From 61f64a468f27225ce71ec2dd2674ea08c682dfa2 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Thu, 27 Aug 2026 20:17:08 -0700 Subject: [PATCH 18/31] Persist CLI warning suppression atomically Use immutable global-storage files for cross-window suppression and bound filesystem failure retries without dropping confirmed update notifications. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/extension.ts | 7 +- .../src/test/outdatedCliNotifier.test.ts | 248 +++++++++++++----- .../test/outdatedCliSuppressionStore.test.ts | 67 +++++ extension/src/utils/outdatedCliNotifier.ts | 175 +++++++----- .../src/utils/outdatedCliSuppressionStore.ts | 128 +++++++++ 5 files changed, 498 insertions(+), 127 deletions(-) create mode 100644 extension/src/test/outdatedCliSuppressionStore.test.ts create mode 100644 extension/src/utils/outdatedCliSuppressionStore.ts diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 438d22e899e..2f32bcac9bf 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -40,6 +40,7 @@ import { registerCodeLensCommands } from './activation/registerCodeLensCommands' import { initializeHotReloadAdvisory } from './debugger/hotReload'; import { OutdatedCliNotifier } from './utils/outdatedCliNotifier'; import { onDidResolveCliForOperation } from './utils/cliOperationResolution'; +import { FileSystemOutdatedCliSuppressionStore } from './utils/outdatedCliSuppressionStore'; let aspireExtensionContext = new AspireExtensionContext(); @@ -103,7 +104,11 @@ export async function activate(context: vscode.ExtensionContext) { terminalProvider.closeAllOpenAspireTerminals(); const configInfoProvider = new ConfigInfoProvider(terminalProvider); - const outdatedCliNotifier = new OutdatedCliNotifier(configInfoProvider, undefined, Date.now, context.globalState); + const outdatedCliNotifier = new OutdatedCliNotifier( + configInfoProvider, + undefined, + Date.now, + new FileSystemOutdatedCliSuppressionStore(context.globalStorageUri.fsPath)); context.subscriptions.push(outdatedCliNotifier); context.subscriptions.push(onDidResolveCliForOperation(({ target, cliPath }) => { void outdatedCliNotifier.notifyIfOutdated(target, cliPath).catch(error => { diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index b88998c8625..b39122df811 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -12,6 +12,10 @@ import { import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier'; import { onDidResolveCliForOperation, startCliOperationResolutionHeartbeat } from '../utils/cliOperationResolution'; +import { + OutdatedCliSuppressionStore, + PersistedCliSuppression, +} from '../utils/outdatedCliSuppressionStore'; suite('outdatedCliNotifier', () => { class FakeVersionProvider { @@ -67,7 +71,7 @@ suite('outdatedCliNotifier', () => { } } - function createNotifier(now: () => number = Date.now, globalState?: vscode.Memento): { + function createNotifier(now: () => number = Date.now, suppressionStore?: OutdatedCliSuppressionStore): { notifier: OutdatedCliNotifier; versionProvider: FakeVersionProvider; surface: FakeSurface; @@ -75,24 +79,27 @@ suite('outdatedCliNotifier', () => { const versionProvider = new FakeVersionProvider(); const surface = new FakeSurface(); return { - notifier: new OutdatedCliNotifier(versionProvider, surface, now, globalState), + notifier: new OutdatedCliNotifier(versionProvider, surface, now, suppressionStore), versionProvider, surface, }; } - function createMemento(values = new Map()): vscode.Memento { + type StoredSuppression = Omit; + let inMemoryStorageSequence = 0; + + function createSuppressionStore( + values = new Map(), + ): OutdatedCliSuppressionStore { return { - keys: () => [...values.keys()], - get: (key: string, defaultValue?: T): T | undefined => - values.has(key) ? values.get(key) as T : defaultValue, - update: async (key: string, value: unknown): Promise => { - if (value === undefined) { - values.delete(key); - } else { - values.set(key, value); - } + readAll: async () => [...values].map(([storageKey, value]) => ({ storageKey, ...value })), + write: async (notificationKey, suppressedAt) => { + const storageKey = `suppression-${inMemoryStorageSequence++}`; + const suppression = { notificationKey, storageKey, suppressedAt }; + values.set(storageKey, { notificationKey, suppressedAt }); + return suppression; }, + delete: async storageKey => void values.delete(storageKey), }; } @@ -139,8 +146,8 @@ suite('outdatedCliNotifier', () => { }); test("Don't Show Again persists for the exact CLI path and version", async () => { - const globalState = createMemento(); - const first = createNotifier(Date.now, globalState); + const values = new Map(); + const first = createNotifier(Date.now, createSuppressionStore(values)); first.surface.selection = strings.dontShowAgainLabel; await first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); @@ -149,7 +156,7 @@ suite('outdatedCliNotifier', () => { assert.deepStrictEqual(first.surface.commands, []); first.notifier.dispose(); - const second = createNotifier(Date.now, globalState); + const second = createNotifier(Date.now, createSuppressionStore(values)); await second.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); assert.deepStrictEqual(second.surface.warnings, []); @@ -157,7 +164,7 @@ suite('outdatedCliNotifier', () => { assert.deepStrictEqual(second.versionProvider.recommendationCalls, []); second.notifier.dispose(); - const third = createNotifier(Date.now, globalState); + const third = createNotifier(Date.now, createSuppressionStore(values)); third.versionProvider.identity = { cliPath: '/cli/aspire', version: '13.5.1', @@ -173,11 +180,126 @@ suite('outdatedCliNotifier', () => { third.notifier.dispose(); }); + test("Don't Show Again is observed by an already-open window before it shows UI", async () => { + const values = new Map(); + const first = createNotifier(Date.now, createSuppressionStore(values)); + const second = createNotifier(Date.now, createSuppressionStore(values)); + first.surface.selection = strings.dontShowAgainLabel; + let completeSecondRecommendation!: (recommendation: CliUpdateRecommendation) => void; + second.versionProvider.recommendationPromise = new Promise( + resolve => completeSecondRecommendation = resolve); + + const secondNotification = second.notifier.notifyIfOutdated( + windowCliPathTarget, + '/cli/aspire'); + await waitFor( + () => second.versionProvider.recommendationCalls.length === 1, + 'Expected second window update probe to start.'); + await first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + completeSecondRecommendation({ + status: 'available', + currentVersion: '13.5.0', + version: '13.6.0', + }); + await secondNotification; + + assert.strictEqual(first.surface.warnings.length, 1); + assert.deepStrictEqual(second.surface.warnings, []); + first.notifier.dispose(); + second.notifier.dispose(); + }); + + test('suppression read failures stay silent and use a bounded retry', async () => { + let now = 0; + let readCount = 0; + const suppressionStore: OutdatedCliSuppressionStore = { + readAll: async () => { + readCount++; + throw new Error('storage unavailable'); + }, + write: async () => { + throw new Error('Unexpected write.'); + }, + delete: async () => { + throw new Error('Unexpected delete.'); + }, + }; + const { notifier, versionProvider, surface } = createNotifier(() => now, suppressionStore); + + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + now = 30 * 1_000; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + now = 60 * 1_000; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual(readCount, 2); + assert.deepStrictEqual(versionProvider.identityCalls, []); + assert.deepStrictEqual(surface.warnings, []); + notifier.dispose(); + }); + + test('final suppression read failure retries an available update after storage backoff', async () => { + let now = 0; + let readCount = 0; + const suppressionStore: OutdatedCliSuppressionStore = { + readAll: async () => { + readCount++; + if (readCount === 2) { + throw new Error('storage unavailable'); + } + return []; + }, + write: async () => { + throw new Error('Unexpected write.'); + }, + delete: async () => { + throw new Error('Unexpected delete.'); + }, + }; + const { notifier, versionProvider, surface } = createNotifier(() => now, suppressionStore); + + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + assert.deepStrictEqual(surface.warnings, []); + now = 30 * 1_000; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + now = 60 * 1_000; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual(readCount, 4); + assert.strictEqual(versionProvider.recommendationCalls.length, 2); + assert.strictEqual(surface.warnings.length, 1); + notifier.dispose(); + }); + + test("Don't Show Again remains effective for the session when persistence fails", async () => { + let now = 0; + const suppressionStore: OutdatedCliSuppressionStore = { + readAll: async () => [], + write: async () => { + throw new Error('storage unavailable'); + }, + delete: async () => { + throw new Error('Unexpected delete.'); + }, + }; + const { notifier, versionProvider, surface } = createNotifier(() => now, suppressionStore); + surface.selection = strings.dontShowAgainLabel; + + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + now = 5 * 60 * 1_000; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual(surface.warnings.length, 1); + assert.strictEqual(versionProvider.recommendationCalls.length, 1); + notifier.dispose(); + }); + test("Don't Show Again evicts the oldest persisted suppression", async () => { let now = 0; - const values = new Map(); - const globalState = createMemento(values); - const { notifier, versionProvider, surface } = createNotifier(() => now, globalState); + const values = new Map(); + const { notifier, versionProvider, surface } = createNotifier( + () => now, + createSuppressionStore(values)); surface.selection = strings.dontShowAgainLabel; for (let index = 0; index <= 100; index++) { @@ -191,24 +313,30 @@ suite('outdatedCliNotifier', () => { } assert.strictEqual(values.size, 100); - const persistedValues = [...values.values()] as Array<{ suppressedAt: number }>; - assert.strictEqual(persistedValues.some(value => value.suppressedAt === 0), false); - assert.strictEqual(persistedValues.some(value => value.suppressedAt === 100), true); + assert.strictEqual([...values.values()].some(value => value.suppressedAt === 0), false); + assert.strictEqual([...values.values()].some(value => value.suppressedAt === 100), true); notifier.dispose(); }); test("Don't Show Again persists concurrent suppressions independently across windows", async () => { - const values = new Map(); - const pendingUpdates: Array<{ key: string; value: unknown; complete: () => void }> = []; - const globalState: vscode.Memento = { - keys: () => [...values.keys()], - get: (key: string, defaultValue?: T): T | undefined => - values.has(key) ? values.get(key) as T : defaultValue, - update: (key: string, value: unknown): Thenable => - new Promise(resolve => pendingUpdates.push({ key, value, complete: resolve })), - }; - const first = createNotifier(Date.now, globalState); - const second = createNotifier(Date.now, globalState); + const values = new Map(); + const pendingWrites: Array<{ complete: () => void }> = []; + const createDelayedStore = (): OutdatedCliSuppressionStore => ({ + readAll: async () => [...values].map(([storageKey, value]) => ({ storageKey, ...value })), + write: (notificationKey, suppressedAt) => { + const storageKey = `suppression-${inMemoryStorageSequence++}`; + const suppression = { notificationKey, storageKey, suppressedAt }; + return new Promise(resolve => pendingWrites.push({ + complete: () => { + values.set(storageKey, { notificationKey, suppressedAt }); + resolve(suppression); + }, + })); + }, + delete: async storageKey => void values.delete(storageKey), + }); + const first = createNotifier(Date.now, createDelayedStore()); + const second = createNotifier(Date.now, createDelayedStore()); first.surface.selection = strings.dontShowAgainLabel; second.surface.selection = strings.dontShowAgainLabel; second.versionProvider.identity = { @@ -220,20 +348,15 @@ suite('outdatedCliNotifier', () => { first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'), second.notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire'), ]; - await waitFor(() => pendingUpdates.length === 2, 'Expected independent suppression writes.'); - for (const update of pendingUpdates.splice(0)) { - if (update.value === undefined) { - values.delete(update.key); - } else { - values.set(update.key, update.value); - } - update.complete(); + await waitFor(() => pendingWrites.length === 2, 'Expected independent suppression writes.'); + for (const write of pendingWrites.splice(0)) { + write.complete(); } await Promise.all(suppressions); first.notifier.dispose(); second.notifier.dispose(); - const third = createNotifier(Date.now, globalState); + const third = createNotifier(Date.now, createSuppressionStore(values)); await third.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); third.versionProvider.identity = { cliPath: '/other/aspire', @@ -248,36 +371,36 @@ suite('outdatedCliNotifier', () => { test("Don't Show Again compaction cannot delete a concurrent refresh", async () => { let now = 0; - const values = new Map(); + const values = new Map(); let blockNextDeletion = false; let blockedDeletion: { complete: () => void } | undefined; - const globalState: vscode.Memento = { - keys: () => [...values.keys()], - get: (key: string, defaultValue?: T): T | undefined => - values.has(key) ? values.get(key) as T : defaultValue, - update: (key: string, value: unknown): Thenable => { - if (value === undefined && blockNextDeletion) { + const createBlockingStore = (): OutdatedCliSuppressionStore => ({ + readAll: async () => [...values].map(([storageKey, value]) => ({ storageKey, ...value })), + write: async (notificationKey, suppressedAt) => { + const storageKey = `suppression-${inMemoryStorageSequence++}`; + const suppression = { notificationKey, storageKey, suppressedAt }; + values.set(storageKey, { notificationKey, suppressedAt }); + return suppression; + }, + delete: storageKey => { + if (blockNextDeletion) { blockNextDeletion = false; return new Promise(resolve => { blockedDeletion = { complete: () => { - values.delete(key); + values.delete(storageKey); resolve(); }, }; }); } - if (value === undefined) { - values.delete(key); - } else { - values.set(key, value); - } + values.delete(storageKey); return Promise.resolve(); }, - }; + }); - const staleWindow = createNotifier(() => now, globalState); + const staleWindow = createNotifier(() => now, createBlockingStore()); staleWindow.versionProvider.identity = { cliPath: '/cli/0/aspire', version: '13.5.0', @@ -291,7 +414,7 @@ suite('outdatedCliNotifier', () => { () => staleWindow.surface.warnings.length === 1, 'Expected stale window warning to open.'); - const seeder = createNotifier(() => now, globalState); + const seeder = createNotifier(() => now, createBlockingStore()); seeder.surface.selection = strings.dontShowAgainLabel; for (let index = 0; index < 100; index++) { const cliPath = `/cli/${index}/aspire`; @@ -305,7 +428,7 @@ suite('outdatedCliNotifier', () => { seeder.notifier.dispose(); blockNextDeletion = true; - const compactor = createNotifier(() => now, globalState); + const compactor = createNotifier(() => now, createBlockingStore()); compactor.surface.selection = strings.dontShowAgainLabel; compactor.versionProvider.identity = { cliPath: '/cli/new/aspire', @@ -322,7 +445,7 @@ suite('outdatedCliNotifier', () => { blockedDeletion?.complete(); await compaction; - const freshWindow = createNotifier(() => now, globalState); + const freshWindow = createNotifier(() => now, createBlockingStore()); freshWindow.versionProvider.identity = { cliPath: '/cli/0/aspire', version: '13.5.0', @@ -339,8 +462,9 @@ suite('outdatedCliNotifier', () => { test("Don't Show Again on a stale warning does not suppress the replacement version", async () => { let now = 0; - const globalState = createMemento(); - const { notifier, versionProvider, surface } = createNotifier(() => now, globalState); + const { notifier, versionProvider, surface } = createNotifier( + () => now, + createSuppressionStore()); let resolveOldSelection!: (selection: string | undefined) => void; surface.selectionPromise = new Promise(resolve => resolveOldSelection = resolve); diff --git a/extension/src/test/outdatedCliSuppressionStore.test.ts b/extension/src/test/outdatedCliSuppressionStore.test.ts new file mode 100644 index 00000000000..36bbd184f37 --- /dev/null +++ b/extension/src/test/outdatedCliSuppressionStore.test.ts @@ -0,0 +1,67 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { FileSystemOutdatedCliSuppressionStore } from '../utils/outdatedCliSuppressionStore'; + +suite('outdatedCliSuppressionStore', () => { + let directoryPath: string; + + setup(() => { + directoryPath = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-suppressions-')); + }); + + teardown(() => { + fs.rmSync(directoryPath, { recursive: true, force: true }); + }); + + test('shares immutable generations across store instances', async () => { + const first = new FileSystemOutdatedCliSuppressionStore(directoryPath); + const second = new FileSystemOutdatedCliSuppressionStore(directoryPath); + + const [firstSuppression, secondSuppression] = await Promise.all([ + first.write('/cli/a\u000013.5.0', 1), + second.write('/cli/b\u000013.5.0', 2), + ]); + + assert.notStrictEqual(firstSuppression.storageKey, secondSuppression.storageKey); + assert.deepStrictEqual( + (await first.readAll()) + .map(({ notificationKey, suppressedAt }) => ({ notificationKey, suppressedAt })) + .sort((left, right) => left.suppressedAt - right.suppressedAt), + [ + { notificationKey: '/cli/a\u000013.5.0', suppressedAt: 1 }, + { notificationKey: '/cli/b\u000013.5.0', suppressedAt: 2 }, + ]); + + await first.delete(firstSuppression.storageKey); + + assert.deepStrictEqual( + (await second.readAll()).map(({ notificationKey }) => notificationKey), + ['/cli/b\u000013.5.0']); + }); + + test('creates missing storage and ignores incomplete or malformed entries', async () => { + const missingDirectory = path.join(directoryPath, 'missing'); + const store = new FileSystemOutdatedCliSuppressionStore(missingDirectory); + + assert.deepStrictEqual(await store.readAll(), []); + fs.writeFileSync( + path.join(missingDirectory, '.outdated-cli-suppression-incomplete.json.tmp'), + '{"notificationKey":"incomplete"}'); + fs.writeFileSync( + path.join(missingDirectory, 'outdated-cli-suppression-malformed.json'), + '{'); + fs.writeFileSync( + path.join(missingDirectory, 'outdated-cli-suppression-oversized.json'), + 'x'.repeat(256 * 1024 + 1)); + const valid = await store.write('/cli/valid\u000013.5.0', 3); + + assert.deepStrictEqual( + (await store.readAll()).map(({ notificationKey }) => notificationKey), + ['/cli/valid\u000013.5.0']); + await store.delete('outdated-cli-suppression-missing.json'); + await store.delete(valid.storageKey); + assert.deepStrictEqual(await store.readAll(), []); + }); +}); diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 889a2069201..6a3464948d3 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -10,6 +10,11 @@ import { resolveConfigInfoWorkingDirectory, } from './configInfoProvider'; import { CliPathResolutionTarget, getCliPathTargetKey } from './cliPathVariables'; +import { + OutdatedCliSuppressionStore, + PersistedCliSuppression, +} from './outdatedCliSuppressionStore'; +import { extensionLogOutputChannel } from './logging'; import { getComparisonKey } from './paths/comparison'; const updateAspireCliCommand = 'aspire-vscode.updateSelf'; @@ -19,9 +24,7 @@ const completedUpdateRefreshIntervalMs = 6 * 60 * 60 * 1_000; const unavailableRetryBaseMs = 60 * 1_000; const unavailableRetryMaximumMs = 30 * 60 * 1_000; const maximumUnavailableAttemptsPerIdentity = 3; -const persistedSuppressionKeyPrefix = 'outdatedCliNotification.suppressedCliVersion.'; const maximumPersistedSuppressions = 100; -let persistedSuppressionSequence = 0; type CliVersionProvider = Pick; @@ -45,12 +48,6 @@ interface PendingNotification { recommendedVersion: string; } -interface PersistedSuppression { - notificationKey: string; - storageKey: string; - suppressedAt: number; -} - const defaultSurface: OutdatedCliNotificationSurface = { showWarning: (message, ...actions) => vscode.window.showWarningMessage(message, ...actions), executeCommand: (command, ...args) => vscode.commands.executeCommand(command, ...args), @@ -68,16 +65,17 @@ export class OutdatedCliNotifier implements vscode.Disposable { private readonly _cancellationSource = new vscode.CancellationTokenSource(); private readonly _versionLimiter = new AsyncLimiter(4); private readonly _doctorLimiter = new AsyncLimiter(1); + private _sharedSuppressionRefresh: Promise | undefined; + private _suppressionReadRetryAfter = 0; private _disposed = false; constructor( private readonly _versionProvider: CliVersionProvider, private readonly _surface: OutdatedCliNotificationSurface = defaultSurface, private readonly _now: () => number = Date.now, - private readonly _globalState?: vscode.Memento, + private readonly _suppressionStore?: OutdatedCliSuppressionStore, ) { - this._persistentlySuppressedCliVersions = new Set( - readPersistedSuppressions(_globalState).map(suppression => suppression.notificationKey)); + this._persistentlySuppressedCliVersions = new Set(); } async notifyIfOutdated(target: CliPathResolutionTarget, cliPath: string): Promise { @@ -90,6 +88,9 @@ export class OutdatedCliNotifier implements vscode.Disposable { if ((this._stateByCheckKey.get(checkKey)?.versionValidUntil ?? 0) > this._now()) { return; } + if (!await this._refreshPersistedSuppressions(true) || this._disposed) { + return; + } const existingProbe = this._inFlightByCheckKey.get(checkKey); if (existingProbe) { @@ -115,6 +116,15 @@ export class OutdatedCliNotifier implements vscode.Disposable { return; } + // Another VS Code window can persist suppression while this window's probes are in flight. + const suppressionRefreshSucceeded = await this._refreshPersistedSuppressions(false); + if (this._disposed) { + return; + } + if (!suppressionRefreshSucceeded) { + this._invalidateRecommendationAfterSuppressionReadFailure(notification); + return; + } const notificationKey = getNotificationKey(notification.cli.cliPath, notification.cli.version); if (this._notifiedCliVersions.has(notificationKey) || this._persistentlySuppressedCliVersions.has(notificationKey)) { @@ -159,6 +169,67 @@ export class OutdatedCliNotifier implements vscode.Disposable { await this._surface.executeCommand(updateAspireCliCommand, notification.target, notification.cli.cliPath); } + private _invalidateRecommendationAfterSuppressionReadFailure(notification: PendingNotification): void { + const state = this._stateByCheckKey.get(notification.checkKey); + if (!state?.identity || + !areCliIdentitiesEqual(state.identity, notification.cli)) { + return; + } + + // The update is known to be available, but UI cannot be shown safely until all persisted + // suppressions are readable. Retry after the storage backoff instead of hiding it behind the + // normal six-hour successful-update cache. + state.versionValidUntil = this._suppressionReadRetryAfter; + state.updateStatus = undefined; + state.updateValidUntil = 0; + state.failureCount = 0; + } + + private async _refreshPersistedSuppressions(useSharedRefresh: boolean): Promise { + if (!this._suppressionStore) { + return true; + } + if (this._suppressionReadRetryAfter > this._now()) { + return false; + } + if (useSharedRefresh && this._sharedSuppressionRefresh) { + return await this._sharedSuppressionRefresh; + } + + const suppressionStore = this._suppressionStore; + let refresh!: Promise; + refresh = this._readPersistedSuppressions(suppressionStore).finally(() => { + if (this._sharedSuppressionRefresh === refresh) { + this._sharedSuppressionRefresh = undefined; + } + }); + if (useSharedRefresh) { + this._sharedSuppressionRefresh = refresh; + } + return await refresh; + } + + private async _readPersistedSuppressions( + suppressionStore: OutdatedCliSuppressionStore, + ): Promise { + try { + for (const persistedSuppression of await suppressionStore.readAll()) { + this._persistentlySuppressedCliVersions.add(persistedSuppression.notificationKey); + } + this._suppressionReadRetryAfter = 0; + return true; + } + catch (error) { + this._recordSuppressionReadFailure('Unable to read Aspire CLI warning suppressions', error); + return false; + } + } + + private _recordSuppressionReadFailure(message: string, error: unknown): void { + this._suppressionReadRetryAfter = this._now() + versionFailureRetryMs; + extensionLogOutputChannel.warn(`${message}: ${String(error)}`); + } + private async _checkForUpdate( target: CliPathResolutionTarget, checkKey: string, @@ -294,29 +365,35 @@ export class OutdatedCliNotifier implements vscode.Disposable { state.failureCount = 0; } - if (!this._globalState) { + if (!this._suppressionStore) { return; } - // Each VS Code window owns a notifier, while globalState is shared by the profile. Store - // suppressions under independent keys so concurrent choices cannot overwrite one another. + // Each VS Code window owns a notifier. Immutable generations in global storage let windows + // coordinate without relying on asynchronously synchronized Memento snapshots. const suppressedAt = this._now(); - const persistedSuppression = { - notificationKey, - storageKey: getPersistedSuppressionStorageKey(suppressedAt), - suppressedAt, - }; - await this._globalState.update(persistedSuppression.storageKey, { - notificationKey, - suppressedAt, - }); - const persistedSuppressions = readPersistedSuppressions(this._globalState); + try { + await this._suppressionStore.write(notificationKey, suppressedAt); + } + catch (error) { + extensionLogOutputChannel.warn(`Unable to persist Aspire CLI warning suppression: ${String(error)}`); + return; + } + + let persistedSuppressions: PersistedCliSuppression[]; + try { + persistedSuppressions = await this._suppressionStore.readAll(); + } + catch (error) { + this._recordSuppressionReadFailure('Unable to compact Aspire CLI warning suppressions', error); + return; + } for (const persistedSuppression of persistedSuppressions) { this._persistentlySuppressedCliVersions.add(persistedSuppression.notificationKey); } - const latestSuppressionByNotification = new Map(); - const staleSuppressions: PersistedSuppression[] = []; + const latestSuppressionByNotification = new Map(); + const staleSuppressions: PersistedCliSuppression[] = []; for (const suppression of persistedSuppressions) { const previous = latestSuppressionByNotification.get(suppression.notificationKey); if (!previous || comparePersistedSuppressions(previous, suppression) < 0) { @@ -337,7 +414,13 @@ export class OutdatedCliNotifier implements vscode.Disposable { for (const suppression of [...staleSuppressions, ...cappedSuppressions]) { // Storage keys identify immutable generations. Deleting an observed generation cannot // remove a newer suppression written concurrently by another VS Code window. - await this._globalState.update(suppression.storageKey, undefined); + try { + await this._suppressionStore.delete(suppression.storageKey); + } + catch (error) { + extensionLogOutputChannel.warn( + `Unable to delete stale Aspire CLI warning suppression '${suppression.storageKey}': ${String(error)}`); + } } } @@ -369,47 +452,11 @@ function getCliCheckKey( return `${getCliPathTargetKey(target)}\u0000${getComparisonKey(path.normalize(cliPath))}\u0000${getComparisonKey(path.normalize(workingDirectory))}`; } -function getPersistedSuppressionStorageKey(suppressedAt: number): string { - return `${persistedSuppressionKeyPrefix}${suppressedAt}.${process.pid}.${persistedSuppressionSequence++}`; -} - -function comparePersistedSuppressions(left: PersistedSuppression, right: PersistedSuppression): number { +function comparePersistedSuppressions(left: PersistedCliSuppression, right: PersistedCliSuppression): number { return left.suppressedAt - right.suppressedAt || (left.storageKey < right.storageKey ? -1 : left.storageKey > right.storageKey ? 1 : 0); } -function readPersistedSuppressions(globalState: vscode.Memento | undefined): PersistedSuppression[] { - if (!globalState) { - return []; - } - - const suppressions: PersistedSuppression[] = []; - for (const storageKey of globalState.keys()) { - if (!storageKey.startsWith(persistedSuppressionKeyPrefix)) { - continue; - } - - const value = globalState.get(storageKey); - if (typeof value !== 'object' || value === null) { - continue; - } - - const { notificationKey, suppressedAt } = value as { - notificationKey?: unknown; - suppressedAt?: unknown; - }; - if (typeof notificationKey !== 'string' || - typeof suppressedAt !== 'number' || - !Number.isFinite(suppressedAt)) { - continue; - } - - suppressions.push({ notificationKey, storageKey, suppressedAt }); - } - - return suppressions; -} - function areCliIdentitiesEqual(left: CliIdentityInfo | undefined, right: CliIdentityInfo): boolean { return left?.version === right.version && left.identityChannelOverride === right.identityChannelOverride; diff --git a/extension/src/utils/outdatedCliSuppressionStore.ts b/extension/src/utils/outdatedCliSuppressionStore.ts new file mode 100644 index 00000000000..cb2b02b3dc0 --- /dev/null +++ b/extension/src/utils/outdatedCliSuppressionStore.ts @@ -0,0 +1,128 @@ +import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'fs/promises'; +import * as path from 'path'; +import { extensionLogOutputChannel } from './logging'; + +const suppressionFilePrefix = 'outdated-cli-suppression-'; +const suppressionFileSuffix = '.json'; +const maximumSuppressionFileLength = 256 * 1024; +let suppressionFileSequence = 0; + +export interface PersistedCliSuppression { + notificationKey: string; + storageKey: string; + suppressedAt: number; +} + +export interface OutdatedCliSuppressionStore { + readAll(): Promise; + write(notificationKey: string, suppressedAt: number): Promise; + delete(storageKey: string): Promise; +} + +/** + * Uses one immutable file per suppression generation so separate VS Code extension hosts never + * overwrite one another's cached state object. Publishing by rename keeps readers from observing + * partially written JSON. + */ +export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppressionStore { + constructor(private readonly _directoryPath: string) { + } + + async readAll(): Promise { + await mkdir(this._directoryPath, { recursive: true }); + const entries = await readdir(this._directoryPath, { withFileTypes: true }); + const suppressions: PersistedCliSuppression[] = []; + + for (const entry of entries) { + if (!entry.isFile() || + !entry.name.startsWith(suppressionFilePrefix) || + !entry.name.endsWith(suppressionFileSuffix)) { + continue; + } + + const filePath = path.join(this._directoryPath, entry.name); + try { + if ((await stat(filePath)).size > maximumSuppressionFileLength) { + extensionLogOutputChannel.warn(`Ignoring oversized Aspire CLI warning suppression file '${entry.name}'.`); + continue; + } + + // Suppression files contain: + // { "notificationKey": "\\u0000", "suppressedAt": 1787880000000 } + const value = JSON.parse(await readFile(filePath, 'utf8')) as { + notificationKey?: unknown; + suppressedAt?: unknown; + }; + if (typeof value.notificationKey !== 'string' || + typeof value.suppressedAt !== 'number' || + !Number.isFinite(value.suppressedAt)) { + extensionLogOutputChannel.warn(`Ignoring malformed Aspire CLI warning suppression file '${entry.name}'.`); + continue; + } + + suppressions.push({ + notificationKey: value.notificationKey, + storageKey: entry.name, + suppressedAt: value.suppressedAt, + }); + } + catch (error) { + if (isFileNotFoundError(error)) { + continue; + } + if (error instanceof SyntaxError) { + extensionLogOutputChannel.warn(`Ignoring malformed Aspire CLI warning suppression file '${entry.name}'.`); + continue; + } + throw new Error( + `Unable to read Aspire CLI warning suppression file '${entry.name}': ${String(error)}`); + } + } + + return suppressions; + } + + async write(notificationKey: string, suppressedAt: number): Promise { + await mkdir(this._directoryPath, { recursive: true }); + const generation = `${suppressedAt}-${process.pid}-${suppressionFileSequence++}`; + const storageKey = `${suppressionFilePrefix}${generation}${suppressionFileSuffix}`; + const temporaryPath = path.join(this._directoryPath, `.${storageKey}.tmp`); + const storagePath = path.join(this._directoryPath, storageKey); + + await writeFile( + temporaryPath, + JSON.stringify({ notificationKey, suppressedAt }), + { encoding: 'utf8', flag: 'wx' }); + try { + await rename(temporaryPath, storagePath); + } + catch (error) { + await unlink(temporaryPath).catch(cleanupError => { + if (!isFileNotFoundError(cleanupError)) { + extensionLogOutputChannel.warn( + `Unable to remove temporary Aspire CLI warning suppression file: ${String(cleanupError)}`); + } + }); + throw error; + } + + return { notificationKey, storageKey, suppressedAt }; + } + + async delete(storageKey: string): Promise { + try { + await unlink(path.join(this._directoryPath, storageKey)); + } + catch (error) { + if (!isFileNotFoundError(error)) { + throw error; + } + } + } +} + +function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT'; +} From 6bb47841fef783df01452f5a2c0640e54174f538 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 28 Aug 2026 10:39:17 -0700 Subject: [PATCH 19/31] Simplify outdated CLI notifications Restore deploy and publish resolution ownership, remove redundant reporting and lifecycle machinery, trust Doctor's published channel, and consolidate probe and suppression state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- .../src/activation/registerCliCommands.ts | 79 +-- extension/src/commands/deploy.ts | 10 +- extension/src/commands/publish.ts | 10 +- extension/src/commands/update.ts | 6 +- extension/src/data/AppHostDataRepository.ts | 71 +- extension/src/data/appHostCliRunner.ts | 12 +- extension/src/data/appHostPsPoller.ts | 16 +- .../src/editor/AspireEditorCommandProvider.ts | 8 +- extension/src/extension.ts | 3 +- extension/src/services/AppHostStopper.ts | 5 +- .../cliPathRejectionNotification.e2e.test.ts | 51 +- extension/src/test-e2e/helpers/fixtures.ts | 40 -- extension/src/test-e2e/helpers/vscode.ts | 19 +- extension/src/test/appHostCliRunner.test.ts | 29 +- .../src/test/appHostDataRepository.test.ts | 210 +----- extension/src/test/appHostDiscovery.test.ts | 32 - extension/src/test/appHostStopper.test.ts | 8 - .../test/aspirePackageRestoreProvider.test.ts | 5 - extension/src/test/configInfoProvider.test.ts | 252 -------- .../test/notificationWaitForMessage.test.ts | 17 - .../src/test/outdatedCliNotifier.test.ts | 606 ++---------------- .../test/outdatedCliSuppressionStore.test.ts | 67 -- .../src/test/registerCliCommands.test.ts | 68 +- .../src/test/resourceCommandArguments.test.ts | 5 - .../src/utils/AspirePackageRestoreProvider.ts | 5 +- extension/src/utils/AspireTerminalProvider.ts | 3 +- extension/src/utils/appHostDiscovery.ts | 42 +- extension/src/utils/cliOperationResolution.ts | 27 - extension/src/utils/configInfoProvider.ts | 411 ++++-------- extension/src/utils/outdatedCliNotifier.ts | 319 ++------- .../src/utils/outdatedCliSuppressionStore.ts | 128 ---- .../views/ResourceCommandArgumentsLoader.ts | 5 +- 32 files changed, 298 insertions(+), 2271 deletions(-) delete mode 100644 extension/src/test/outdatedCliSuppressionStore.test.ts delete mode 100644 extension/src/utils/outdatedCliSuppressionStore.ts diff --git a/extension/src/activation/registerCliCommands.ts b/extension/src/activation/registerCliCommands.ts index 8706068f0ff..2f5596942cf 100644 --- a/extension/src/activation/registerCliCommands.ts +++ b/extension/src/activation/registerCliCommands.ts @@ -31,11 +31,16 @@ interface CommandInvocation { readonly cliPath?: string; } -interface RequiredAppHostCommandInvocation extends CommandInvocation { - readonly appHost: AppHostCommandTarget & { appHostPath: string }; -} - type CommandSource = 'command_palette' | 'tree'; +const cliCheckExcludedCommands = new Set([ + 'aspire-vscode.settings', + 'aspire-vscode.configureLaunchJson', + 'aspire-vscode.updateSelf', +]); +const cliCheckDeferredCommands = new Set([ + 'aspire-vscode.deploy', + 'aspire-vscode.publish', +]); export function registerCliCommands( terminalProvider: AspireTerminalProvider, @@ -49,16 +54,8 @@ export function registerCliCommands( // through tryExecuteCommand itself — the delegated-to command owns its own CLI // availability check and telemetry. const createWithAspireCommandRegistration = registerInstrumentedCommand('aspire-vscode.createWithAspire', 'tree', createWithAspireCommand); - const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand( - 'aspire-vscode.deploy', - terminalProvider, - (_tp, invocation, cliPath) => deployCommand(editorCommandProvider, invocation.appHost.appHostPath, invocation.target, cliPath), - () => selectAppHostCommandInvocation(editorCommandProvider, true))); - const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand( - 'aspire-vscode.publish', - terminalProvider, - (_tp, invocation, cliPath) => publishCommand(editorCommandProvider, invocation.appHost.appHostPath, invocation.target, cliPath), - () => selectAppHostCommandInvocation(editorCommandProvider, true))); + const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand('aspire-vscode.deploy', terminalProvider, () => deployCommand(editorCommandProvider))); + const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', terminalProvider, () => publishCommand(editorCommandProvider))); const cliDoCommandRegistration = vscode.commands.registerCommand('aspire-vscode.do', () => tryExecuteCommand('aspire-vscode.do', terminalProvider, (_tp, invocation, cliPath) => doCommand(configInfoProvider, editorCommandProvider, invocation.appHost?.appHostPath, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider, true))); const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, (tp, invocation, cliPath) => updateCommand(tp, editorCommandProvider, invocation.appHost ?? {}, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider))); const cliUpdateSelfCommandRegistration = vscode.commands.registerCommand('aspire-vscode.updateSelf', (target: CliPathResolutionTarget = windowCliPathTarget, cliPath?: string) => @@ -68,10 +65,10 @@ export function registerCliCommands( (tp, invocation, resolvedCliPath) => updateSelfCommand(tp, invocation.target, resolvedCliPath || undefined), async () => ({ target, cliPath }))); const openTerminalCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openTerminal', () => tryExecuteCommand('aspire-vscode.openTerminal', terminalProvider, (tp, invocation, cliPath) => openTerminalCommand(tp, invocation.target, cliPath), selectCommandInvocation)); - const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', terminalProvider, configureLaunchJsonCommand, selectWindowCommandInvocation)); - const settingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.settings', () => tryExecuteCommand('aspire-vscode.settings', terminalProvider, settingsCommand, selectWindowCommandInvocation)); + const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', terminalProvider, configureLaunchJsonCommand)); + const settingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.settings', () => tryExecuteCommand('aspire-vscode.settings', terminalProvider, settingsCommand)); const openLocalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openLocalSettings', () => tryExecuteCommand('aspire-vscode.openLocalSettings', terminalProvider, (tp, invocation, cliPath) => openLocalSettingsCommand(tp, invocation.target, cliPath), selectCommandInvocation)); - const openGlobalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openGlobalSettings', () => tryExecuteCommand('aspire-vscode.openGlobalSettings', terminalProvider, openGlobalSettingsCommand, selectWindowCommandInvocation)); + const openGlobalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openGlobalSettings', () => tryExecuteCommand('aspire-vscode.openGlobalSettings', terminalProvider, openGlobalSettingsCommand)); const runAppHostCommandRegistration = registerInstrumentedCommand('aspire-vscode.runAppHostCommand', 'editor', (resource?: vscode.Uri) => editorCommandProvider.tryExecuteRunAppHost(true, resource)); const debugAppHostCommandRegistration = registerInstrumentedCommand('aspire-vscode.debugAppHostCommand', 'editor', (resource?: vscode.Uri) => editorCommandProvider.tryExecuteRunAppHost(false, resource)); const runAppHostFromExplorerRegistration = registerInstrumentedCommand('aspire-vscode.runAppHostFromExplorer', 'editor', (resource?: vscode.Uri) => editorCommandProvider.tryExecuteRunAppHost(true, resource, false)); @@ -137,25 +134,11 @@ async function selectCommandInvocation(): Promise { return { target: await selectCommandTarget() }; } -async function selectWindowCommandInvocation(): Promise { - return { target: windowCliPathTarget }; -} - -function selectAppHostCommandInvocation(editorCommandProvider: AspireEditorCommandProvider, requireAppHost: true): Promise; -function selectAppHostCommandInvocation(editorCommandProvider: AspireEditorCommandProvider, requireAppHost?: false): Promise; async function selectAppHostCommandInvocation(editorCommandProvider: AspireEditorCommandProvider, requireAppHost = false): Promise { const appHost = await getAppHostArgs(editorCommandProvider); - if (requireAppHost) { - const appHostPath = appHost.appHostPath; - if (!appHostPath) { - vscode.window.showErrorMessage(noAppHostInWorkspace); - throw new vscode.CancellationError(); - } - - return { - target: getCliPathTargetForUri(vscode.Uri.file(appHostPath)), - appHost: { ...appHost, appHostPath }, - }; + if (!appHost.appHostPath && requireAppHost) { + vscode.window.showErrorMessage(noAppHostInWorkspace); + throw new vscode.CancellationError(); } const target = appHost.appHostPath @@ -164,19 +147,18 @@ async function selectAppHostCommandInvocation(editorCommandProvider: AspireEdito return { target, appHost }; } -async function tryExecuteCommand( +async function tryExecuteCommand( commandName: string, terminalProvider: AspireTerminalProvider, - command: (terminalProvider: AspireTerminalProvider, invocation: TInvocation, cliPath: string) => Promise, - prepareInvocation: () => Promise, + command: (terminalProvider: AspireTerminalProvider, invocation: CommandInvocation, cliPath: string) => Promise, + prepareInvocation: () => Promise = async () => ({ target: windowCliPathTarget }), source: CommandSource = 'command_palette', ): Promise { try { await withCommandTelemetry(commandName, async () => { const invocation = await prepareInvocation(); let cliPath = invocation.cliPath ?? ''; - const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson", "aspire-vscode.updateSelf"]; - if (!cliCheckExcludedCommands.includes(commandName)) { + if (!cliCheckExcludedCommands.has(commandName)) { if (isE2eBridgeEnabled() && process.env.ASPIRE_EXTENSION_E2E_FORCE_CLI_UNAVAILABLE === 'true') { vscode.window.showErrorMessage( cliNotAvailable, @@ -186,16 +168,17 @@ async function tryExecuteCommand( throw new vscode.CancellationError(); } - const result = await checkCliAvailableOrRedirect('command_gate', invocation.target); - if (!result.available) { - // The command body never ran — the user was redirected to install the - // CLI. Throwing a cancellation makes withCommandTelemetry record this - // as `canceled` rather than a false `success`, and the catch below - // suppresses the error toast (the redirect already informed the user). - throw new vscode.CancellationError(); + if (!cliCheckDeferredCommands.has(commandName)) { + const result = await checkCliAvailableOrRedirect('command_gate', invocation.target); + if (!result.available) { + // The command body never ran — the user was redirected to install the + // CLI. Throwing a cancellation makes withCommandTelemetry record this + // as `canceled` rather than a false `success`, and the catch below + // suppresses the error toast (the redirect already informed the user). + throw new vscode.CancellationError(); + } + cliPath = result.cliPath; } - cliPath = result.cliPath; - } await command(terminalProvider, invocation, cliPath); diff --git a/extension/src/commands/deploy.ts b/extension/src/commands/deploy.ts index 7f714d8473f..41116b75fe7 100644 --- a/extension/src/commands/deploy.ts +++ b/extension/src/commands/deploy.ts @@ -1,11 +1,5 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; -import { CliPathResolutionTarget } from '../utils/cliPathVariables'; -export async function deployCommand( - editorCommandProvider: AspireEditorCommandProvider, - appHostPath: string, - target: CliPathResolutionTarget, - cliPath: string, -) { - await editorCommandProvider.tryExecuteDeployAppHost(false, appHostPath, target, cliPath); +export async function deployCommand(editorCommandProvider: AspireEditorCommandProvider) { + await editorCommandProvider.tryExecuteDeployAppHost(false); } diff --git a/extension/src/commands/publish.ts b/extension/src/commands/publish.ts index 7ce57045570..40eace33ed1 100644 --- a/extension/src/commands/publish.ts +++ b/extension/src/commands/publish.ts @@ -1,11 +1,5 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; -import { CliPathResolutionTarget } from '../utils/cliPathVariables'; -export async function publishCommand( - editorCommandProvider: AspireEditorCommandProvider, - appHostPath: string, - target: CliPathResolutionTarget, - cliPath: string, -) { - await editorCommandProvider.tryExecutePublishAppHost(false, appHostPath, target, cliPath); +export async function publishCommand(editorCommandProvider: AspireEditorCommandProvider) { + await editorCommandProvider.tryExecutePublishAppHost(false); } diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts index 0332567726c..0cdacb8eba4 100644 --- a/extension/src/commands/update.ts +++ b/extension/src/commands/update.ts @@ -18,8 +18,6 @@ export async function updateSelfCommand( target: CliPathResolutionTarget = windowCliPathTarget, cliPath?: string, ) { - const options = cliPath - ? { target, cliPath, reportCliResolution: false } - : { target, reportCliResolution: false }; - await terminalProvider.sendAspireCommandToAspireTerminal('update --self', true, undefined, options); + const resolvedCliPath = cliPath ?? await terminalProvider.getAspireCliExecutablePath(target); + await terminalProvider.sendAspireCommandToAspireTerminal('update --self', true, undefined, { target, cliPath: resolvedCliPath }); } diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index 48e166452bf..1663c31e860 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -18,7 +18,7 @@ import { isMatchingAppHostInstance, isMatchingAppHostPath, isPathInWorkspace } f import { AppHostPsPoller } from './appHostPsPoller'; import { filterResourceCommandStatusOutput } from './resourceCommandStatusOutput'; import { getCliPathTargetForUri } from '../utils/cliPathVariables'; -import { reportCliResolvedForOperation, startCliOperationResolutionHeartbeat } from '../utils/cliOperationResolution'; +import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; export * from './appHostCliContracts'; export { shortenPath, shortenPaths }; @@ -48,7 +48,6 @@ interface DescribeStream { nonJsonLines: string[]; stderr: string; restartTimer: ReturnType | undefined; - cliResolutionHeartbeat: vscode.Disposable | undefined; restartDelay: number; version: number; } @@ -129,7 +128,6 @@ export class AppHostDataRepository { private _workspaceAppHostDiscoveryProgressResolve: (() => void) | undefined; private _workspaceAppHostDiscoveryCancellationSource: vscode.CancellationTokenSource | undefined; private readonly _appHostDiscoveryChangeDisposable: vscode.Disposable; - private readonly _appHostDiscoveryCliResolutionDisposable: vscode.Disposable; private readonly _workspaceFoldersChangeDisposable: vscode.Disposable; private readonly _appHostDiscoveryService: AppHostDiscoveryService; private readonly _ownsAppHostDiscoveryService: boolean; @@ -174,13 +172,6 @@ export class AppHostDataRepository { this._fetchWorkspaceAppHost(); } }); - this._appHostDiscoveryCliResolutionDisposable = this._appHostDiscoveryService.onDidResolveCli - ? this._appHostDiscoveryService.onDidResolveCli(({ workspaceFolder, cliPath }) => { - if (this._dataActive) { - reportCliResolvedForOperation(getCliPathTargetForUri(workspaceFolder.uri), cliPath); - } - }) - : vscode.Disposable.from(); this._workspaceFoldersChangeDisposable = vscode.workspace.onDidChangeWorkspaceFolders(event => { this._removeWorkspaceFolderCandidates(event.removed); for (const workspaceFolder of event.removed) { @@ -194,20 +185,7 @@ export class AppHostDataRepository { }); this._fetchWorkspaceAppHost(); this._configChangeDisposable = vscode.workspace.onDidChangeConfiguration(e => { - const cliPathChanged = e.affectsConfiguration('aspire.aspireCliExecutablePath'); - if (cliPathChanged) { - this._cancelWorkspaceAppHostDiscovery(); - this._markWorkspaceAppHostDiscoveryPending({ preserveCandidates: true }); - if (this._dataActive) { - this._fetchWorkspaceAppHost({ forceRefresh: true }); - this._psPoller.startPsPolling(); - this._stopAllDescribes(); - this._reconcileDescribes(); - } - } - if (!cliPathChanged && - (e.affectsConfiguration('aspire.appHostsPollingInterval') || e.affectsConfiguration('aspire.globalAppHostsPollingInterval')) && - this._dataActive) { + if ((e.affectsConfiguration('aspire.appHostsPollingInterval') || e.affectsConfiguration('aspire.globalAppHostsPollingInterval')) && this._dataActive) { this._psPoller.startPsPolling(); } }); @@ -309,9 +287,6 @@ export class AppHostDataRepository { if (this._dataActive) { this._hasEverBeenDataActive = true; } - if (becameDataActive) { - this._handleDataSourcesActivated(); - } this._syncPolling(resumedFromInactive); } @@ -325,9 +300,6 @@ export class AppHostDataRepository { const becameDataActive = !wasDataActive && this._dataActive; const resumedFromInactive = becameDataActive && this._hasEverBeenDataActive; this._hasEverBeenDataActive = true; - if (becameDataActive) { - this._handleDataSourcesActivated(); - } this._syncPolling(resumedFromInactive); let disposed = false; @@ -367,9 +339,6 @@ export class AppHostDataRepository { if (this._dataActive) { this._hasEverBeenDataActive = true; } - if (becameDataActive) { - this._handleDataSourcesActivated(); - } // Re-scope the displayed AppHosts against the new open-tab set right away. this._handlePsSnapshot(this._appHosts, { force: true }); this._syncPolling(resumedFromInactive); @@ -567,7 +536,6 @@ export class AppHostDataRepository { cancellationToken, env: nonInteractiveCliEnvironment, target, - reportCliResolution: true, }); return { stdout: filterResourceCommandStatusOutput(output.stdout, resourceName, commandName), @@ -597,7 +565,6 @@ export class AppHostDataRepository { this._cancelWorkspaceAppHostDiscovery(); this._configChangeDisposable.dispose(); this._appHostDiscoveryChangeDisposable.dispose(); - this._appHostDiscoveryCliResolutionDisposable.dispose(); this._workspaceFoldersChangeDisposable.dispose(); this._psPollerDisposable.dispose(); this._psPoller.dispose(); @@ -607,23 +574,6 @@ export class AppHostDataRepository { } } - private _reportWorkspaceDiscoveryCliResolutions(): void { - for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { - const cliPath = this._appHostDiscoveryService.getResolvedCliPath?.(workspaceFolder); - if (cliPath) { - reportCliResolvedForOperation(getCliPathTargetForUri(workspaceFolder.uri), cliPath); - } - } - } - - private _handleDataSourcesActivated(): void { - if (!this._workspaceAppHostDiscoveryComplete && !this._workspaceAppHostDiscoveryInProgress) { - this._fetchWorkspaceAppHost({ forceRefresh: true }); - return; - } - this._reportWorkspaceDiscoveryCliResolutions(); - } - // ── PS polling lifecycle ── /** @@ -1049,7 +999,6 @@ export class AppHostDataRepository { nonJsonLines: [], stderr: '', restartTimer: undefined, - cliResolutionHeartbeat: undefined, // A fresh stream restarts after 5s; a restart carries the backed-off delay forward via // `initialRestartDelay` so repeated no-data exits grow the interval (5s -> 10s -> 20s ...) // instead of hammering the CLI every 5s. Each stream is single-use, so the backoff has to @@ -1145,8 +1094,6 @@ export class AppHostDataRepository { } extensionLogOutputChannel.info(`aspire describe --follow (--apphost ${appHostPath}) exited with code ${code}`); - stream.cliResolutionHeartbeat?.dispose(); - stream.cliResolutionHeartbeat = undefined; stream.process = undefined; if (this._disposed) { @@ -1215,8 +1162,6 @@ export class AppHostDataRepository { // surfaces it through the shared describe banner; a non-selected peer is logged only so it // can't masquerade as the selected host's error. extensionLogOutputChannel.warn(`aspire describe --follow --apphost ${appHostPath} error: ${error.message}`); - stream.cliResolutionHeartbeat?.dispose(); - stream.cliResolutionHeartbeat = undefined; stream.process = undefined; stream.resources.clear(); if (isMatchingAppHostPath(appHostPath, this._workspaceAppHostPath)) { @@ -1238,11 +1183,6 @@ export class AppHostDataRepository { } }); stream.process = describeProcess; - const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath)); - stream.cliResolutionHeartbeat = startCliOperationResolutionHeartbeat( - target, - cliPath, - () => this._describeStreams.get(appHostPath) === stream && stream.process === describeProcess); } private _scheduleDescribeRestart(appHostPath: string, stream: DescribeStream): void { @@ -1378,10 +1318,7 @@ export class AppHostDataRepository { } private async _runCliJson(command: string, args: string[], options: RunCliCommandOptions = {}): Promise { - const { stdout } = await this._cliRunner.runCliCommand(command, args, { - ...options, - reportCliResolution: true, - }); + const { stdout } = await this._cliRunner.runCliCommand(command, args, options); try { return parseCliJsonOutput(stdout); @@ -1409,8 +1346,6 @@ export class AppHostDataRepository { clearTimeout(stream.restartTimer); stream.restartTimer = undefined; } - stream.cliResolutionHeartbeat?.dispose(); - stream.cliResolutionHeartbeat = undefined; if (stream.process) { const childProcess = stream.process; stream.process = undefined; diff --git a/extension/src/data/appHostCliRunner.ts b/extension/src/data/appHostCliRunner.ts index 524d8865153..cd3d1f50c22 100644 --- a/extension/src/data/appHostCliRunner.ts +++ b/extension/src/data/appHostCliRunner.ts @@ -19,7 +19,6 @@ export interface RunCliCommandOptions { env?: { name: string; value: string }[]; target?: CliPathResolutionTarget; cliPath?: string; - reportCliResolution?: boolean; } export class AppHostCliRunner implements vscode.Disposable { @@ -89,9 +88,7 @@ export class AppHostCliRunner implements vscode.Disposable { if (options.cancellationToken?.isCancellationRequested) { throw new vscode.CancellationError(); } - if (options.reportCliResolution) { - reportCliResolvedForOperation(target, cliPath); - } + reportCliResolvedForOperation(target, cliPath); const invocationArgs = this.normalizeNoLogoArgs(cliPath, args); return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { @@ -150,12 +147,7 @@ export class AppHostCliRunner implements vscode.Disposable { const retryArgs = this.tryGetNoLogoRetryArgs(cliPath, invocationArgs, stdout.value, stderr.value, command); if (retryArgs) { settle(() => { - this.runCliCommand(command, retryArgs, { - ...options, - cliPath, - target, - reportCliResolution: false, - }).then(resolve, reject); + this.runCliCommand(command, retryArgs, options).then(resolve, reject); }); return; } diff --git a/extension/src/data/appHostPsPoller.ts b/extension/src/data/appHostPsPoller.ts index 6a512f251d5..4b6832549d3 100644 --- a/extension/src/data/appHostPsPoller.ts +++ b/extension/src/data/appHostPsPoller.ts @@ -6,7 +6,7 @@ import { extensionLogOutputChannel } from '../utils/logging'; import { errorFetchingAppHosts } from '../loc/strings'; import { AppHostCliRunner, LimitedOutputBuffer, oneShotOutputBufferLimit } from './appHostCliRunner'; import { windowCliPathTarget } from '../utils/cliPathVariables'; -import { reportCliResolvedForOperation, startCliOperationResolutionHeartbeat } from '../utils/cliOperationResolution'; +import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; export interface PsOutput { readonly stdout: string; @@ -36,7 +36,6 @@ export class AppHostPsPoller implements vscode.Disposable { private _pollingInterval: ReturnType | undefined; private _psProcesses = new Set(); - private _psFollowCliResolutionHeartbeat: vscode.Disposable | undefined; private _psPollingGeneration = 0; private _psFetchVersion = 0; private _supportsPsFollow = true; @@ -120,8 +119,6 @@ export class AppHostPsPoller implements vscode.Disposable { this._pollingInterval = undefined; extensionLogOutputChannel.info(`aspire ps polling stopped`); } - this._psFollowCliResolutionHeartbeat?.dispose(); - this._psFollowCliResolutionHeartbeat = undefined; for (const psProcess of this._psProcesses) { void terminateCliProcess(psProcess, 'aspire ps').catch(error => { extensionLogOutputChannel.error(`Failed to terminate aspire ps: ${String(error)}`); @@ -167,14 +164,9 @@ export class AppHostPsPoller implements vscode.Disposable { reportCliResolvedForOperation(windowCliPathTarget, cliPath); let psProcess: ChildProcessWithoutNullStreams | undefined; - let cliResolutionHeartbeat: vscode.Disposable | undefined; let psProcessCompletedSynchronously = false; let callbackInvoked = false; const removePsProcess = () => { - cliResolutionHeartbeat?.dispose(); - if (this._psFollowCliResolutionHeartbeat === cliResolutionHeartbeat) { - this._psFollowCliResolutionHeartbeat = undefined; - } if (psProcess) { this._psProcesses.delete(psProcess); } else { @@ -244,12 +236,6 @@ export class AppHostPsPoller implements vscode.Disposable { }); if (!psProcessCompletedSynchronously) { this._psProcesses.add(psProcess); - this._psFollowCliResolutionHeartbeat?.dispose(); - cliResolutionHeartbeat = startCliOperationResolutionHeartbeat( - windowCliPathTarget, - cliPath, - () => this._isCurrentPsFetch(fetchVersion) && this._psProcesses.has(psProcess!)); - this._psFollowCliResolutionHeartbeat = cliResolutionHeartbeat; } this._psFollowStartPending = false; diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index f4fd59d9295..0ee05d1725f 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -129,12 +129,12 @@ export class AspireEditorCommandProvider implements vscode.Disposable { await this.launchAspireDebugSession('run', noDebug, undefined, await this.getAppHostPath(resource, allowWorkspaceFallback)); } - public async tryExecuteDeployAppHost(noDebug: boolean, appHostPath: string, target: CliPathResolutionTarget, cliPath: string): Promise { - await this.launchAspireDebugSession('deploy', noDebug, undefined, appHostPath, target, cliPath); + public async tryExecuteDeployAppHost(noDebug: boolean): Promise { + await this.launchAspireDebugSession('deploy', noDebug); } - public async tryExecutePublishAppHost(noDebug: boolean, appHostPath: string, target: CliPathResolutionTarget, cliPath: string): Promise { - await this.launchAspireDebugSession('publish', noDebug, undefined, appHostPath, target, cliPath); + public async tryExecutePublishAppHost(noDebug: boolean): Promise { + await this.launchAspireDebugSession('publish', noDebug); } public async tryExecuteDoAppHost(noDebug: boolean, doStep?: string, appHostPath?: string, target?: CliPathResolutionTarget, cliPath?: string): Promise { diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 2f32bcac9bf..9c6f899a951 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -40,7 +40,6 @@ import { registerCodeLensCommands } from './activation/registerCodeLensCommands' import { initializeHotReloadAdvisory } from './debugger/hotReload'; import { OutdatedCliNotifier } from './utils/outdatedCliNotifier'; import { onDidResolveCliForOperation } from './utils/cliOperationResolution'; -import { FileSystemOutdatedCliSuppressionStore } from './utils/outdatedCliSuppressionStore'; let aspireExtensionContext = new AspireExtensionContext(); @@ -108,7 +107,7 @@ export async function activate(context: vscode.ExtensionContext) { configInfoProvider, undefined, Date.now, - new FileSystemOutdatedCliSuppressionStore(context.globalStorageUri.fsPath)); + context.globalState); context.subscriptions.push(outdatedCliNotifier); context.subscriptions.push(onDidResolveCliForOperation(({ target, cliPath }) => { void outdatedCliNotifier.notifyIfOutdated(target, cliPath).catch(error => { diff --git a/extension/src/services/AppHostStopper.ts b/extension/src/services/AppHostStopper.ts index 8961365651a..6101cf0273e 100644 --- a/extension/src/services/AppHostStopper.ts +++ b/extension/src/services/AppHostStopper.ts @@ -2,7 +2,6 @@ import * as vscode from 'vscode'; import { spawnCliProcess, terminateCliProcess } from '../utils/process/cliProcess'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { getCliPathTargetForUri } from '../utils/cliPathVariables'; -import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; const maxRetainedStderrLength = 16 * 1024; @@ -12,12 +11,10 @@ export async function stopExternalAppHost( cancellationToken: vscode.CancellationToken, ): Promise { - const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath)); - const cliPath = await terminalProvider.getAspireCliExecutablePath(target); + const cliPath = await terminalProvider.getAspireCliExecutablePath(getCliPathTargetForUri(vscode.Uri.file(appHostPath))); if (cancellationToken.isCancellationRequested) { throw new vscode.CancellationError(); } - reportCliResolvedForOperation(target, cliPath); await new Promise((resolve, reject) => { let settled = false; diff --git a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts index 098e08c1faa..fd07af7c146 100644 --- a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts +++ b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts @@ -1,18 +1,15 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; -import { getCommandInvocationCount, getTerminalCommandCount, waitForCommandOutcome, waitForRepositoryIdle, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; +import { waitForRepositoryIdle, waitForWorkspaceAppHost } from './helpers/assertions'; import { - executeE2eControlCommand, getCliWrapperInvocationCount, restoreE2eCliPathForE2E, restoreWorkspaceCliPath, runE2eTeardown, setE2eCliPathForE2E, - setTerminalCommandExecutionSuppressedForE2E, touchPrimaryAppHostProject, waitForCliWrapperInvocation, - writeCliUpdateWarningWrapper, writeTrackedStreamingDiscoveryCliWrapper, writeWorkspaceCliPath, } from './helpers/fixtures'; @@ -23,24 +20,15 @@ import { openAspireView, waitForNotificationMessage, waitForWorkbenchText } from // Mirrors configuredCliPathRejected in src/loc/strings.ts. const rejectionNotificationText = 'The configured Aspire CLI path could not be used'; const openSettingActionText = 'Open Setting'; -const cliUpdateWarningText = 'Aspire CLI 13.5.0 at '; -const cliUpdateGuidanceText = 'has a newer version available for its current channel: 13.6.0'; -const updateCliActionText = 'Update Aspire CLI'; suite('Configured CLI path rejection E2E', function () { this.timeout(300000); - let cleanupOutdatedCliWrapper: (() => void) | undefined; - teardown(async () => { await runE2eTeardown([ - () => executeE2eControlCommand({ name: 'closeAllEditors' }), - () => setTerminalCommandExecutionSuppressedForE2E(false), () => restoreE2eCliPathForE2E(), () => restoreWorkspaceCliPath(), - () => cleanupOutdatedCliWrapper?.(), ], 'Configured CLI path rejection E2E teardown failed.'); - cleanupOutdatedCliWrapper = undefined; }); test('resolves a configured directory that contains the aspire executable', async () => { @@ -104,41 +92,4 @@ suite('Configured CLI path rejection E2E', function () { assert.ok(settingsText.length > 0, 'The Aspire CLI executable path setting was not shown.'); }); - - test('warns when active Aspire use resolves an outdated CLI and routes the update action', async () => { - await openAspireView(); - await waitForRepositoryIdle(); - const wrapper = writeCliUpdateWarningWrapper(); - cleanupOutdatedCliWrapper = wrapper.cleanup; - await setE2eCliPathForE2E(wrapper.cliPath); - await setTerminalCommandExecutionSuppressedForE2E(true); - - await openAspireView(); - const beforeOpenTerminal = getCommandInvocationCount('aspire-vscode.openTerminal'); - await executeE2eControlCommand({ - name: 'executeAspireCommand', - commandId: 'aspire-vscode.openTerminal', - }); - await waitForCommandOutcome('aspire-vscode.openTerminal', 'success', 60_000, beforeOpenTerminal); - - const notification = await waitForNotificationMessage(cliUpdateWarningText, 60_000); - const message = await notification.getMessage(); - await VSBrowser.instance.takeScreenshot('outdated-aspire-cli-warning').catch(() => undefined); - assert.ok(message.includes(cliUpdateWarningText), `Unexpected warning message: ${message}`); - assert.ok(message.includes(wrapper.cliPath), `Warning did not identify the exact CLI '${wrapper.cliPath}': ${message}`); - assert.ok(message.includes(cliUpdateGuidanceText), `Warning did not include the expected update guidance: ${message}`); - - const beforeTerminalCommand = getTerminalCommandCount(); - await notification.takeAction(updateCliActionText); - const terminalCommand = await waitForTerminalCommand( - event => event.executionSuppressed && event.subcommand === 'update --self', - 'outdated CLI update action', - 60_000, - beforeTerminalCommand); - - assert.strictEqual(terminalCommand.executionSuppressed, true); - assert.ok( - terminalCommand.commandLine.includes(wrapper.cliPath), - `Expected update command to use the warned CLI '${wrapper.cliPath}': ${terminalCommand.commandLine}`); - }); }); diff --git a/extension/src/test-e2e/helpers/fixtures.ts b/extension/src/test-e2e/helpers/fixtures.ts index 6eae5725801..00811894619 100644 --- a/extension/src/test-e2e/helpers/fixtures.ts +++ b/extension/src/test-e2e/helpers/fixtures.ts @@ -442,24 +442,6 @@ export function writeTrackedStreamingDiscoveryCliWrapper(delayMs = 4_000, initia return { cliPath, invocationLogPath }; } -export function writeCliUpdateWarningWrapper(version = '13.5.0', recommendedVersion = '13.6.0'): { cliPath: string; cleanup: () => void } { - const name = 'aspire-cli-update-warning'; - const cliPath = writeCliWrapper(name, { - versionOutput: version, - recommendedVersion, - identityChannel: version.includes('-') ? 'daily' : 'stable', - }); - const scriptPath = path.join(path.dirname(cliPath), `${name}.js`); - - return { - cliPath, - cleanup: () => { - removePath(cliPath, { force: true }); - removePath(scriptPath, { force: true }); - }, - }; -} - export function getCliWrapperInvocationCount(invocationLogPath: string): number { if (!fs.existsSync(invocationLogPath)) { return 0; @@ -975,8 +957,6 @@ function writeCliWrapper( psSnapshotAppHostPath?: string; psSnapshotAppHostPid?: number; versionOutput?: string; - recommendedVersion?: string; - identityChannel?: string; }, wrapperDirectory = path.join(getWorkspaceRoot(), '.e2e-cli-wrappers'), ): string { @@ -1012,26 +992,6 @@ ${options.versionOutput === undefined process.exit(0); } -`} -${options.recommendedVersion === undefined - ? '' - : `if (args[0] === 'doctor' && args.includes('--format') && args[args.indexOf('--format') + 1] === 'json') { - console.log(JSON.stringify({ - checks: [{ - name: 'cli-version', - metadata: { - currentVersion: ${JSON.stringify(options.versionOutput)}, - latestVersion: ${JSON.stringify(options.recommendedVersion)}, - identityChannel: ${JSON.stringify(options.identityChannel)}, - latestVersionChannel: ${JSON.stringify(options.recommendedVersion.includes('-') ? 'prerelease' : 'stable')}, - }, - }], - summary: { passed: 0, warnings: 1, failed: 0 }, - installations: [], - })); - process.exit(0); -} - `} if (args.includes('--include-disabled-commands')) { console.error('simulated old CLI does not support --include-disabled-commands'); diff --git a/extension/src/test-e2e/helpers/vscode.ts b/extension/src/test-e2e/helpers/vscode.ts index ef6cfa27d81..0acb23d27e1 100644 --- a/extension/src/test-e2e/helpers/vscode.ts +++ b/extension/src/test-e2e/helpers/vscode.ts @@ -357,22 +357,9 @@ export async function waitForNotificationMessage(expectedText: string, timeoutMs try { const notifications = await new Workbench().getNotifications(); for (const notification of notifications) { - try { - const message = await notification.getMessage(); - if (message.includes(expectedText)) { - return notification; - } - } - catch (error) { - // VS Code keeps dismissed notifications in the DOM briefly. ExTester waits for - // visibility before reading them, so skip a hidden stale entry and continue to - // the current visible notification in the same poll. - if (error instanceof webDriverError.StaleElementReferenceError || - error instanceof webDriverError.TimeoutError) { - continue; - } - - throw error; + const message = await notification.getMessage(); + if (message.includes(expectedText)) { + return notification; } } diff --git a/extension/src/test/appHostCliRunner.test.ts b/extension/src/test/appHostCliRunner.test.ts index b6580fdca4e..999e1337b51 100644 --- a/extension/src/test/appHostCliRunner.test.ts +++ b/extension/src/test/appHostCliRunner.test.ts @@ -151,6 +151,8 @@ suite('data/appHostCliRunner tests', () => { }); const runner = new AppHostCliRunner(terminalProvider); + const resolutions: string[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); try { await runner.runCliCommand('list pipeline steps', ['do', '--list-steps'], { cliPath: '/repo/tools/aspire', @@ -158,32 +160,7 @@ suite('data/appHostCliRunner tests', () => { assert.strictEqual(getCliPathStub.called, false); assert.strictEqual(spawnStub.firstCall.args[1], '/repo/tools/aspire'); - } finally { - runner.dispose(); - } - }); - - test('reports an exact CLI only when the caller marks it as an operation', async () => { - const cliProcess = new TestChildProcess(); - spawnStub.callsFake((_provider: unknown, _cliPath: string, _args: string[], options: cliModule.SpawnProcessOptions) => { - cliProcess.exitCode = 0; - options.processExitCallback?.(0); - return cliProcess; - }); - const resolutions: string[] = []; - const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); - const runner = new AppHostCliRunner(terminalProvider); - - try { - await runner.runCliCommand('pipeline steps', ['do', '--list-steps'], { - cliPath: '/cli/aspire', - }); - await runner.runCliCommand('describe', ['describe'], { - cliPath: '/cli/aspire', - reportCliResolution: true, - }); - - assert.deepStrictEqual(resolutions, ['/cli/aspire']); + assert.deepStrictEqual(resolutions, ['/repo/tools/aspire']); } finally { subscription.dispose(); diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index 746ee584441..d1eb7c30b9e 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -1372,8 +1372,6 @@ suite('AppHostDataRepository', () => { spawnStub.onFirstCall().returns(psProcess); spawnStub.onSecondCall().returns(describeProcess); const repository = new AppHostDataRepository(terminalProvider); - const resolutions: Array<{ target: typeof windowCliPathTarget | typeof target; cliPath: string }> = []; - const resolutionSubscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); try { const fetchPromise = repository.fetchAppHostsOnce(); @@ -1423,15 +1421,7 @@ suite('AppHostDataRepository', () => { assert.strictEqual(describeProcess.killed, true); assert.strictEqual(appHosts.length, 1); assert.strictEqual(appHosts[0].resources?.[0].name, 'api'); - assert.deepStrictEqual(resolutions, [{ - target: windowCliPathTarget, - cliPath: '/global/bin/aspire', - }, { - target, - cliPath: '/workspace/bin/aspire', - }]); } finally { - resolutionSubscription.dispose(); repository.dispose(); getWorkspaceFolderStub.restore(); } @@ -1544,12 +1534,6 @@ suite('AppHostDataRepository', () => { const resourceProcess = new TestChildProcess(); spawnStub.returns(resourceProcess); const repository = new AppHostDataRepository(terminalProvider); - const resolutions: Array<{ target: typeof target; cliPath: string }> = []; - const resolutionSubscription = onDidResolveCliForOperation(resolution => { - if (resolution.target.kind === 'workspaceFolder') { - resolutions.push({ target: resolution.target, cliPath: resolution.cliPath }); - } - }); try { const runPromise = repository.runResourceCommand('api', ' /workspace/AppHost.csproj ', 'stop'); @@ -1565,12 +1549,7 @@ suite('AppHostDataRepository', () => { spawnStub.firstCall.args[3].exitCallback(0); await runPromise; - assert.deepStrictEqual(resolutions, [{ - target, - cliPath: '/workspace/bin/aspire', - }]); } finally { - resolutionSubscription.dispose(); repository.dispose(); getWorkspaceFolderStub.restore(); } @@ -2792,40 +2771,6 @@ suite('AppHostDataRepository', () => { } }); - test('active describe streams periodically re-report their exact CLI paths', async () => { - const resolutions: string[] = []; - const resolutionSubscription = onDidResolveCliForOperation(resolution => { - if (resolution.target.kind === 'workspaceFolder') { - resolutions.push(resolution.cliPath); - } - }); - const clock = sinon.useFakeTimers({ shouldAdvanceTime: true }); - let context: Awaited> | undefined; - - try { - context = await startTwoFolderDescribeStreams(); - assert.deepStrictEqual(resolutions.sort(), ['/cli/peer/aspire', '/cli/selected/aspire']); - - clock.tick(5 * 60 * 1_000); - - assert.deepStrictEqual(resolutions.sort(), [ - '/cli/peer/aspire', - '/cli/peer/aspire', - '/cli/selected/aspire', - '/cli/selected/aspire', - ]); - - context.dispose(); - clock.tick(5 * 60 * 1_000); - assert.strictEqual(resolutions.length, 4); - } - finally { - clock.restore(); - resolutionSubscription.dispose(); - context?.dispose(); - } - }); - test('working peer CLI does not clear the selected AppHost compatibility error', async () => { const context = await startTwoFolderDescribeStreams(); @@ -6294,125 +6239,8 @@ suite('AppHostDataRepository global polling', () => { repository.dispose(); }); - test('reports cached discovery CLI paths only when data sources become active', async () => { - const workspaceFolder: vscode.WorkspaceFolder = { - uri: vscode.Uri.file('/workspace'), - name: 'workspace', - index: 0, - }; - const workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([workspaceFolder]); - const candidateChange = new vscode.EventEmitter(); - const cliResolution = new vscode.EventEmitter<{ workspaceFolder: vscode.WorkspaceFolder; cliPath: string }>(); - const getResolvedCliPath = sinon.stub().returns('/workspace/aspire'); - const discoveryService = { - discover: sinon.stub().resolves([]), - getResolvedCliPath, - onDidResolveCli: cliResolution.event, - onDidChangeCandidates: candidateChange.event, - dispose: () => { }, - } as unknown as AppHostDiscoveryService; - const repository = new AppHostDataRepository(terminalProvider, discoveryService); - const resolutions: string[] = []; - const resolutionSubscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); - - try { - repository.activate(); - await waitForMicrotasks(); - assert.deepStrictEqual(resolutions, []); - - repository.setPanelVisible(true); - assert.deepStrictEqual(resolutions, ['/workspace/aspire']); - - cliResolution.fire({ workspaceFolder, cliPath: '/workspace/aspire-next' }); - assert.deepStrictEqual(resolutions, ['/workspace/aspire', '/workspace/aspire-next']); - - repository.setAppHostFilesOpen(['/workspace/AppHost.csproj']); - assert.strictEqual(resolutions.length, 2); - - repository.setPanelVisible(false); - repository.setAppHostFilesOpen([]); - repository.setPanelVisible(true); - assert.strictEqual(resolutions.length, 3); - } - finally { - resolutionSubscription.dispose(); - repository.dispose(); - candidateChange.dispose(); - cliResolution.dispose(); - workspaceFoldersStub.restore(); - } - }); - - test('a scoped CLI path change refreshes discovery only after data becomes active', async () => { - const workspaceFolder: vscode.WorkspaceFolder = { - uri: vscode.Uri.file('/workspace'), - name: 'workspace', - index: 0, - }; - const workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([workspaceFolder]); - const configurationChange = new vscode.EventEmitter(); - const onDidChangeConfigurationStub = sinon.stub(vscode.workspace, 'onDidChangeConfiguration') - .callsFake(listener => configurationChange.event(listener)); - const candidateChange = new vscode.EventEmitter(); - const cliResolution = new vscode.EventEmitter<{ workspaceFolder: vscode.WorkspaceFolder; cliPath: string }>(); - const discover = sinon.stub().callsFake(async (_workspaceFolder, forceRefresh) => { - cliResolution.fire({ - workspaceFolder, - cliPath: forceRefresh ? '/cli/second/aspire' : '/cli/first/aspire', - }); - return []; - }); - const discoveryService = { - discover, - getResolvedCliPath: sinon.stub().returns('/cli/first/aspire'), - onDidResolveCli: cliResolution.event, - onDidChangeCandidates: candidateChange.event, - dispose: () => { }, - } as unknown as AppHostDiscoveryService; - const repository = new AppHostDataRepository(terminalProvider, discoveryService); - const resolutions: string[] = []; - const resolutionSubscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); - - try { - repository.activate(); - await waitForCondition(() => discover.callCount > 0, 'initial workspace discovery did not complete'); - discover.resetHistory(); - resolutions.length = 0; - - configurationChange.fire({ - affectsConfiguration: section => section === 'aspire.aspireCliExecutablePath', - }); - await waitForMicrotasks(); - assert.strictEqual(discover.callCount, 0); - - repository.setPanelVisible(true); - await waitForCondition(() => discover.callCount > 0, 'CLI path change did not refresh workspace discovery'); - - assert.strictEqual(discover.lastCall.args[1], true); - assert.ok(resolutions.includes('/cli/second/aspire')); - } - finally { - resolutionSubscription.dispose(); - repository.dispose(); - candidateChange.dispose(); - cliResolution.dispose(); - configurationChange.dispose(); - onDidChangeConfigurationStub.restore(); - workspaceFoldersStub.restore(); - } - }); - test('global ps follow and one-shot refresh resolve with the window target', async () => { const repository = new AppHostDataRepository(terminalProvider); - const resolutions: Array<{ target: typeof windowCliPathTarget; cliPath: string }> = []; - const resolutionSubscription = onDidResolveCliForOperation(resolution => { - if (resolution.target.kind === 'window') { - resolutions.push({ - target: resolution.target, - cliPath: resolution.cliPath, - }); - } - }); try { repository.activate(); @@ -6425,41 +6253,7 @@ suite('AppHostDataRepository global polling', () => { assert.deepStrictEqual(getCliPathStub.firstCall.args, [windowCliPathTarget]); assert.deepStrictEqual(getCliPathStub.secondCall.args, [windowCliPathTarget]); - assert.deepStrictEqual(resolutions, [ - { target: windowCliPathTarget, cliPath: 'aspire' }, - { target: windowCliPathTarget, cliPath: 'aspire' }, - ]); } finally { - resolutionSubscription.dispose(); - repository.dispose(); - } - }); - - test('active ps follow periodically re-reports its exact CLI path', async () => { - const clock = sinon.useFakeTimers({ shouldAdvanceTime: true }); - const repository = new AppHostDataRepository(terminalProvider); - const resolutions: string[] = []; - const resolutionSubscription = onDidResolveCliForOperation(resolution => { - if (resolution.target.kind === 'window') { - resolutions.push(resolution.cliPath); - } - }); - - try { - repository.activate(); - repository.setViewMode('global'); - repository.setPanelVisible(true); - await waitForCondition(() => resolutions.length === 1, 'global ps follow did not report its CLI'); - clock.tick(5 * 60 * 1_000); - assert.deepStrictEqual(resolutions, ['aspire', 'aspire']); - - repository.setPanelVisible(false); - clock.tick(5 * 60 * 1_000); - assert.strictEqual(resolutions.length, 2); - } - finally { - clock.restore(); - resolutionSubscription.dispose(); repository.dispose(); } }); @@ -7250,9 +7044,7 @@ suite('AppHostDataRepository global polling', () => { await waitForMicrotasks(); assert.strictEqual(spawnStub.calledTwice, true); - // The fallback polling interval replaces the active follow heartbeat, so the total - // timer count stays constant instead of retaining both. - assert.strictEqual(clock.countTimers(), timerCountBeforeFallback); + assert.strictEqual(clock.countTimers(), timerCountBeforeFallback + 1); } finally { repository.dispose(); clock.restore(); diff --git a/extension/src/test/appHostDiscovery.test.ts b/extension/src/test/appHostDiscovery.test.ts index d32b1252b45..1edfa82690d 100644 --- a/extension/src/test/appHostDiscovery.test.ts +++ b/extension/src/test/appHostDiscovery.test.ts @@ -208,38 +208,6 @@ suite('AppHost discovery', () => { sandbox.restore(); }); - test('publishes resolved CLI data and observes a scoped path change', async () => { - stubFileSystemWatchers(sandbox); - const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); - let cliPath = buildPath('cli', 'first', 'aspire'); - const terminalProvider = { - getAspireCliExecutablePath: async () => cliPath, - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { - emitLsOutput(options, []); - return { kill: () => { } } as any; - }); - const resolutions: string[] = []; - const service = new AppHostDiscoveryService(terminalProvider); - const resolutionSubscription = service.onDidResolveCli(resolution => resolutions.push(resolution.cliPath)); - - try { - await service.discover(workspaceFolder); - assert.deepStrictEqual(resolutions, [cliPath]); - assert.strictEqual(service.getResolvedCliPath(workspaceFolder), cliPath); - - cliPath = buildPath('cli', 'second', 'aspire'); - await service.discover(workspaceFolder, true); - assert.strictEqual(resolutions.at(-1), cliPath); - assert.strictEqual(service.getResolvedCliPath(workspaceFolder), cliPath); - } - finally { - resolutionSubscription.dispose(); - service.dispose(); - } - }); - test('does not force refresh discovery after cached negative editor lookup', async () => { stubFileSystemWatchers(sandbox); const spawnStub = sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { diff --git a/extension/src/test/appHostStopper.test.ts b/extension/src/test/appHostStopper.test.ts index 3ad66201e23..4d63237c27f 100644 --- a/extension/src/test/appHostStopper.test.ts +++ b/extension/src/test/appHostStopper.test.ts @@ -7,7 +7,6 @@ import * as vscode from 'vscode'; import { stopExternalAppHost } from '../services/AppHostStopper'; import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; -import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; suite('AppHostStopper', () => { test('waits for aspire stop to exit successfully', async () => { @@ -54,8 +53,6 @@ suite('AppHostStopper', () => { createEnvironment: () => ({}), sendAspireCommandToAspireTerminal: async () => { }, } as unknown as AspireTerminalProvider; - const resolutions: Array<{ target: unknown; cliPath: string }> = []; - const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); try { const stopping = stopExternalAppHost( @@ -65,17 +62,12 @@ suite('AppHostStopper', () => { await new Promise(resolve => setImmediate(resolve)); assert.ok(getAspireCliExecutablePathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); - assert.deepStrictEqual(resolutions, [{ - target: workspaceFolderCliPathTarget(folder), - cliPath: '/repo/bin/aspire', - }]); childState.exitCode = 0; child.emit('close', 0); await stopping; } finally { - subscription.dispose(); getWorkspaceFolderStub.restore(); (nodeChildProcess.spawn as sinon.SinonStub).restore(); } diff --git a/extension/src/test/aspirePackageRestoreProvider.test.ts b/extension/src/test/aspirePackageRestoreProvider.test.ts index def5f188cff..a0137900cb6 100644 --- a/extension/src/test/aspirePackageRestoreProvider.test.ts +++ b/extension/src/test/aspirePackageRestoreProvider.test.ts @@ -8,7 +8,6 @@ import { AspirePackageRestoreProvider } from '../utils/AspirePackageRestoreProvi import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import * as cliProcessModule from '../utils/process/cliProcess'; -import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; suite('AspirePackageRestoreProvider', () => { let sandbox: sinon.SinonSandbox; @@ -33,8 +32,6 @@ suite('AspirePackageRestoreProvider', () => { }); return childProcess as unknown as ChildProcessWithoutNullStreams; }); - const resolutions: string[] = []; - const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); try { await (provider as any)._runRestore(configUri, folder.uri.fsPath, 'aspire.config.json'); @@ -45,9 +42,7 @@ suite('AspirePackageRestoreProvider', () => { '/repo/workspace/bin/aspire', ['restore'], sinon.match({ workingDirectory: folder.uri.fsPath }))); - assert.deepStrictEqual(resolutions, ['/repo/workspace/bin/aspire']); } finally { - subscription.dispose(); provider.dispose(); } }); diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts index 19da2260281..bf0785598c0 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -1,9 +1,6 @@ import * as assert from 'assert'; import nodeChildProcess = require('child_process'); import { EventEmitter } from 'events'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import type { ChildProcessWithoutNullStreams } from 'child_process'; @@ -329,9 +326,6 @@ suite('configInfoProvider tests', () => { assert.deepStrictEqual(parseCliUpdateRecommendationOutput( createDoctorVersionOutput('13.6.0-preview.1', '13.7.0-preview.1', undefined, 'daily', null)), { status: 'available', currentVersion: '13.6.0-preview.1', version: '13.7.0-preview.1' }); - assert.deepStrictEqual(parseCliUpdateRecommendationOutput( - createDoctorVersionOutput('13.6.0-dev', '13.7.0', undefined, 'local'), 'stable'), - { status: 'ineligible', currentVersion: '13.6.0-dev' }); assert.deepStrictEqual(parseCliUpdateRecommendationOutput( createDoctorVersionOutput('13.6.0-dev', undefined, 'offline', 'local')), { status: 'ineligible', currentVersion: '13.6.0-dev' }); @@ -340,209 +334,6 @@ suite('configInfoProvider tests', () => { { status: 'unavailable' }); }); - test('parseCliUpdateRecommendationOutput accepts captured Aspire doctor payloads', () => { - // Captured from the repo-built 13.6.0-dev CLI. Doctor writes progress to stderr and emits - // this check inside its stdout JSON response. - const prereleaseUpdateOutput = JSON.stringify({ - checks: [{ - category: 'aspire', - name: 'cli-version', - status: 'warning', - message: 'Aspire CLI version 13.6.0-dev (channel: local) is out of date. Latest version is 13.6.0-preview.1.26426.1 (channel: prerelease)', - fix: "Run 'aspire update' to update Aspire CLI.", - metadata: { - currentVersion: '13.6.0-dev', - latestVersion: '13.6.0-preview.1.26426.1', - updateCommand: 'aspire update', - identityChannel: 'local', - latestVersionChannel: 'prerelease', - }, - }], - summary: { passed: 6, warnings: 1, failed: 0 }, - installations: [], - }); - assert.deepStrictEqual( - parseCliUpdateRecommendationOutput(prereleaseUpdateOutput), - { status: 'ineligible', currentVersion: '13.6.0-dev' }); - - // Captured from an up-to-date installed 13.5.2 stable CLI. - const currentStableOutput = JSON.stringify({ - checks: [{ - category: 'aspire', - name: 'cli-version', - status: 'pass', - message: 'Aspire CLI version 13.5.2 (channel: stable)', - metadata: { - currentVersion: '13.5.2', - identityChannel: 'stable', - }, - }], - summary: { passed: 7, warnings: 0, failed: 0 }, - installations: [], - }); - assert.deepStrictEqual( - parseCliUpdateRecommendationOutput(currentStableOutput), - { status: 'none', currentVersion: '13.5.2' }); - }); - - test('getCliIdentity follows environment then install-sidecar channel precedence', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-identity-')); - const cliPath = path.join(directory, process.platform === 'win32' ? 'aspire.exe' : 'aspire'); - fs.writeFileSync(path.join(directory, '.aspire-install.json'), JSON.stringify({ - source: 'script', - channel: 'staging', - })); - let environmentChannel: string | undefined = 'daily'; - const terminalProvider = { - getAspireCliExecutablePath: async () => cliPath, - createEnvironment: () => environmentChannel ? { ASPIRE_CLI_CHANNEL: environmentChannel } : {}, - } as unknown as AspireTerminalProvider; - sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { - assert.strictEqual(command, cliPath); - assert.deepStrictEqual(args, ['--version']); - options?.stdoutCallback?.('13.6.0-preview.1'); - options?.exitCallback?.(0); - return {} as ChildProcessWithoutNullStreams; - }); - const provider = new ConfigInfoProvider(terminalProvider); - - try { - assert.deepStrictEqual( - await provider.getCliIdentity({ cliPath }), - { cliPath, version: '13.6.0-preview.1', identityChannelOverride: 'daily' }); - - environmentChannel = ' stable '; - assert.deepStrictEqual( - await provider.getCliIdentity({ cliPath }), - { cliPath, version: '13.6.0-preview.1', identityChannelOverride: ' stable ' }); - assert.deepStrictEqual( - parseCliUpdateRecommendationOutput( - createDoctorVersionOutput('13.6.0-preview.1', '13.6.0', undefined, 'stable'), - ' stable '), - { status: 'ineligible', currentVersion: '13.6.0-preview.1' }); - - environmentChannel = ' '; - assert.deepStrictEqual( - await provider.getCliIdentity({ cliPath }), - { cliPath, version: '13.6.0-preview.1', identityChannelOverride: ' ' }); - - environmentChannel = undefined; - assert.deepStrictEqual( - await provider.getCliIdentity({ cliPath }), - { cliPath, version: '13.6.0-preview.1', identityChannelOverride: 'staging' }); - - fs.writeFileSync(path.join(directory, '.aspire-install.json'), JSON.stringify({ - source: 'script', - channel: ' daily ', - })); - assert.deepStrictEqual( - await provider.getCliIdentity({ cliPath }), - { cliPath, version: '13.6.0-preview.1', identityChannelOverride: ' daily ' }); - - fs.writeFileSync(path.join(directory, '.aspire-install.json'), 'x'.repeat(64 * 1024 + 1)); - assert.deepStrictEqual( - await provider.getCliIdentity({ cliPath }), - { cliPath, version: '13.6.0-preview.1', identityChannelOverride: undefined }); - } - finally { - fs.rmSync(directory, { recursive: true, force: true }); - } - }); - - test('getCliIdentity reads the sidecar beside a symlink target', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-symlink-')); - const targetDirectory = path.join(root, 'target'); - const linkedDirectory = path.join(root, 'link'); - fs.mkdirSync(targetDirectory); - fs.writeFileSync(path.join(targetDirectory, '.aspire-install.json'), JSON.stringify({ - source: 'script', - channel: 'daily', - })); - const executableName = process.platform === 'win32' ? 'aspire.exe' : 'aspire'; - fs.writeFileSync(path.join(targetDirectory, executableName), ''); - fs.symlinkSync(targetDirectory, linkedDirectory, process.platform === 'win32' ? 'junction' : 'dir'); - const cliPath = path.join(linkedDirectory, executableName); - const terminalProvider = { - getAspireCliExecutablePath: async () => cliPath, - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { - assert.strictEqual(command, cliPath); - assert.deepStrictEqual(args, ['--version']); - options?.stdoutCallback?.('13.6.0-preview.1'); - options?.exitCallback?.(0); - return {} as ChildProcessWithoutNullStreams; - }); - const provider = new ConfigInfoProvider(terminalProvider); - - try { - assert.deepStrictEqual( - await provider.getCliIdentity({ cliPath }), - { cliPath, version: '13.6.0-preview.1', identityChannelOverride: 'daily' }); - } - finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - test('getCliIdentity applies its remaining timeout to sidecar sampling', async () => { - const clock = sinon.useFakeTimers(); - const terminalProvider = { - getAspireCliExecutablePath: async () => '/unused/aspire', - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { - assert.deepStrictEqual(args, ['--version']); - options?.stdoutCallback?.('13.6.0'); - options?.exitCallback?.(0); - return {} as ChildProcessWithoutNullStreams; - }); - sinon.stub(fs.promises, 'realpath').returns(new Promise(() => { })); - const provider = new ConfigInfoProvider(terminalProvider); - - try { - const identity = provider.getCliIdentity({ - cliPath: '/network/aspire', - timeoutMs: 1_000, - }); - await clock.tickAsync(1_000); - - assert.strictEqual(await identity, null); - } - finally { - clock.restore(); - } - }); - - test('getCliIdentity cancels sidecar sampling after the version probe', async () => { - const terminalProvider = { - getAspireCliExecutablePath: async () => '/unused/aspire', - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { - assert.deepStrictEqual(args, ['--version']); - options?.stdoutCallback?.('13.6.0'); - options?.exitCallback?.(0); - return {} as ChildProcessWithoutNullStreams; - }); - const realpathStub = sinon.stub(fs.promises, 'realpath').returns(new Promise(() => { })); - const cancellation = new vscode.CancellationTokenSource(); - const provider = new ConfigInfoProvider(terminalProvider); - - const identity = provider.getCliIdentity({ - cliPath: '/network/aspire', - cancellationToken: cancellation.token, - }); - while (!realpathStub.called) { - await new Promise(resolve => setImmediate(resolve)); - } - cancellation.cancel(); - - assert.strictEqual(await identity, null); - assert.strictEqual(realpathStub.calledOnceWith('/network/aspire'), true); - cancellation.dispose(); - }); - test('getCliUpdateRecommendation accepts structured doctor output on a nonzero exit', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => '/unused/aspire', @@ -576,28 +367,6 @@ suite('configInfoProvider tests', () => { assert.strictEqual(spawnStub.callCount, 1); }); - test('getCliUpdateRecommendation does not retry a structured unavailable result containing nologo', async () => { - const terminalProvider = { - getAspireCliExecutablePath: async () => '/unused/aspire', - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { - assert.strictEqual(command, '/exact/aspire'); - assert.deepStrictEqual(args, ['doctor', '--format', 'json', '--nologo']); - const output = JSON.parse(createDoctorVersionOutput('13.5.0', undefined, 'offline')); - output.checks[0].details = 'A nested command used --nologo.'; - options?.stdoutCallback?.(JSON.stringify(output)); - options?.exitCallback?.(0); - return {} as ChildProcessWithoutNullStreams; - }); - const provider = new ConfigInfoProvider(terminalProvider); - - assert.deepStrictEqual( - await provider.getCliUpdateRecommendation({ cliPath: '/exact/aspire' }), - { status: 'unavailable' }); - assert.strictEqual(spawnStub.callCount, 1); - }); - test('getCliUpdateRecommendation retries without nologo and keeps unavailable checks silent', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => '/unused/aspire', @@ -627,27 +396,6 @@ suite('configInfoProvider tests', () => { assert.strictEqual(showErrorMessage.callCount, 0); }); - test('getCliUpdateRecommendation cancels and terminates an in-flight doctor probe', async () => { - const terminalProvider = { - getAspireCliExecutablePath: async () => '/unused/aspire', - createEnvironment: () => ({}), - } as unknown as AspireTerminalProvider; - const childProcess = { kill: () => true } as unknown as ChildProcessWithoutNullStreams; - sinon.stub(cliModule, 'spawnCliProcess').returns(childProcess); - const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); - const cancellation = new vscode.CancellationTokenSource(); - const provider = new ConfigInfoProvider(terminalProvider); - - const probe = provider.getCliUpdateRecommendation({ - cliPath: '/exact/aspire', - cancellationToken: cancellation.token, - }); - cancellation.cancel(); - - assert.deepStrictEqual(await probe, { status: 'unavailable' }); - sinon.assert.calledOnceWithExactly(terminateStub, childProcess, 'cancelled Aspire CLI update probe'); - }); - test('version and update probes do not settle before cancellation termination completes', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => '/unused/aspire', diff --git a/extension/src/test/notificationWaitForMessage.test.ts b/extension/src/test/notificationWaitForMessage.test.ts index 977dbc00c7d..45c3fb961d0 100644 --- a/extension/src/test/notificationWaitForMessage.test.ts +++ b/extension/src/test/notificationWaitForMessage.test.ts @@ -83,23 +83,6 @@ suite('waitForNotificationMessage', () => { assert.deepStrictEqual(waitState.pollResults, [false, freshNotification]); }); - test('skips a hidden dismissed notification before reading the visible notification', async () => { - const hiddenNotification = createNotification(() => { - throw new webDriverError.TimeoutError('Waiting until element is visible'); - }); - const freshNotification = createNotification('Aspire Dashboard ready'); - const { stub, vscode } = loadNotificationWaitModules(); - - stub.setNotificationPolls([[hiddenNotification, freshNotification]]); - - const notification = await vscode.waitForNotificationMessage('Dashboard ready', 5000); - const waitState = stub.getNotificationWaitState(); - - assert.strictEqual(notification, freshNotification); - assert.strictEqual(waitState.notificationPollCount, 1); - assert.deepStrictEqual(waitState.pollResults, [freshNotification]); - }); - test('propagates non-stale WebDriver failures', async () => { const sessionError = new webDriverError.NoSuchSessionError('session closed'); const { stub, vscode } = loadNotificationWaitModules(); diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index b39122df811..621b2ae7799 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -3,7 +3,6 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as strings from '../loc/strings'; import { - CliIdentityInfo, CliUpdateRecommendation, CliUpdateRecommendationOptions, CliVersionInfo, @@ -11,46 +10,33 @@ import { } from '../utils/configInfoProvider'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier'; -import { onDidResolveCliForOperation, startCliOperationResolutionHeartbeat } from '../utils/cliOperationResolution'; -import { - OutdatedCliSuppressionStore, - PersistedCliSuppression, -} from '../utils/outdatedCliSuppressionStore'; suite('outdatedCliNotifier', () => { class FakeVersionProvider { - identity: CliIdentityInfo | null = { - cliPath: '/cli/aspire', - version: '13.5.0', - }; - identityPromise: Promise | undefined; - currentVersion: CliVersionInfo | null = { + identity: CliVersionInfo | null = { cliPath: '/cli/aspire', version: '13.5.0', }; + identityPromise: Promise | undefined; + currentVersion: CliVersionInfo | null | undefined; recommendation: CliUpdateRecommendation = { status: 'available', currentVersion: '13.5.0', version: '13.6.0', }; - recommendationPromise: Promise | undefined; - readonly identityCalls: Array = []; readonly versionCalls: Array = []; readonly recommendationCalls: Array = []; - async getCliIdentity(options?: CliVersionStatusOptions): Promise { - this.identityCalls.push(options); - return await (this.identityPromise ?? this.identity); - } - async getCliVersion(options?: CliVersionStatusOptions): Promise { this.versionCalls.push(options); - return this.currentVersion; + return this.currentVersion !== undefined + ? this.currentVersion + : await (this.identityPromise ?? this.identity); } async getCliUpdateRecommendation(options?: CliUpdateRecommendationOptions): Promise { this.recommendationCalls.push(options); - return await (this.recommendationPromise ?? this.recommendation); + return this.recommendation; } } @@ -71,7 +57,7 @@ suite('outdatedCliNotifier', () => { } } - function createNotifier(now: () => number = Date.now, suppressionStore?: OutdatedCliSuppressionStore): { + function createNotifier(now: () => number = Date.now, globalState?: vscode.Memento): { notifier: OutdatedCliNotifier; versionProvider: FakeVersionProvider; surface: FakeSurface; @@ -79,27 +65,25 @@ suite('outdatedCliNotifier', () => { const versionProvider = new FakeVersionProvider(); const surface = new FakeSurface(); return { - notifier: new OutdatedCliNotifier(versionProvider, surface, now, suppressionStore), + notifier: new OutdatedCliNotifier(versionProvider, surface, now, globalState), versionProvider, surface, }; } - type StoredSuppression = Omit; - let inMemoryStorageSequence = 0; - - function createSuppressionStore( - values = new Map(), - ): OutdatedCliSuppressionStore { + function createMemento(): vscode.Memento { + const values = new Map(); return { - readAll: async () => [...values].map(([storageKey, value]) => ({ storageKey, ...value })), - write: async (notificationKey, suppressedAt) => { - const storageKey = `suppression-${inMemoryStorageSequence++}`; - const suppression = { notificationKey, storageKey, suppressedAt }; - values.set(storageKey, { notificationKey, suppressedAt }); - return suppression; + keys: () => [...values.keys()], + get: (key: string, defaultValue?: T): T | undefined => + values.has(key) ? values.get(key) as T : defaultValue, + update: async (key: string, value: unknown): Promise => { + if (value === undefined) { + values.delete(key); + } else { + values.set(key, value); + } }, - delete: async storageKey => void values.delete(storageKey), }; } @@ -122,7 +106,12 @@ suite('outdatedCliNotifier', () => { }); versionProvider.identity = { cliPath: '/workspace/a/.aspire/bin/aspire', - version: '13.5.0', + version: '13.4.0', + }; + versionProvider.recommendation = { + status: 'available', + currentVersion: '13.4.0', + version: '15.3.2', }; versionProvider.currentVersion = versionProvider.identity; surface.selection = strings.updateAspireCliAction; @@ -133,7 +122,7 @@ suite('outdatedCliNotifier', () => { assert.strictEqual(surface.warnings.length, 1); assert.strictEqual( surface.warnings[0].message, - 'Aspire CLI 13.5.0 at /workspace/a/.aspire/bin/aspire has a newer version available for its current channel: 13.6.0.'); + 'Aspire CLI 13.4.0 at /workspace/a/.aspire/bin/aspire has a newer version available for its current channel: 15.3.2.'); assert.deepStrictEqual(surface.warnings[0].actions, [ strings.updateAspireCliAction, strings.dontShowAgainLabel, @@ -146,8 +135,8 @@ suite('outdatedCliNotifier', () => { }); test("Don't Show Again persists for the exact CLI path and version", async () => { - const values = new Map(); - const first = createNotifier(Date.now, createSuppressionStore(values)); + const globalState = createMemento(); + const first = createNotifier(Date.now, globalState); first.surface.selection = strings.dontShowAgainLabel; await first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); @@ -156,362 +145,13 @@ suite('outdatedCliNotifier', () => { assert.deepStrictEqual(first.surface.commands, []); first.notifier.dispose(); - const second = createNotifier(Date.now, createSuppressionStore(values)); + const second = createNotifier(Date.now, globalState); await second.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); assert.deepStrictEqual(second.surface.warnings, []); - assert.strictEqual(second.versionProvider.identityCalls.length, 1); + assert.strictEqual(second.versionProvider.versionCalls.length, 1); assert.deepStrictEqual(second.versionProvider.recommendationCalls, []); second.notifier.dispose(); - - const third = createNotifier(Date.now, createSuppressionStore(values)); - third.versionProvider.identity = { - cliPath: '/cli/aspire', - version: '13.5.1', - }; - third.versionProvider.recommendation = { - status: 'available', - currentVersion: '13.5.1', - version: '13.6.0', - }; - await third.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - assert.strictEqual(third.surface.warnings.length, 1); - third.notifier.dispose(); - }); - - test("Don't Show Again is observed by an already-open window before it shows UI", async () => { - const values = new Map(); - const first = createNotifier(Date.now, createSuppressionStore(values)); - const second = createNotifier(Date.now, createSuppressionStore(values)); - first.surface.selection = strings.dontShowAgainLabel; - let completeSecondRecommendation!: (recommendation: CliUpdateRecommendation) => void; - second.versionProvider.recommendationPromise = new Promise( - resolve => completeSecondRecommendation = resolve); - - const secondNotification = second.notifier.notifyIfOutdated( - windowCliPathTarget, - '/cli/aspire'); - await waitFor( - () => second.versionProvider.recommendationCalls.length === 1, - 'Expected second window update probe to start.'); - await first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - completeSecondRecommendation({ - status: 'available', - currentVersion: '13.5.0', - version: '13.6.0', - }); - await secondNotification; - - assert.strictEqual(first.surface.warnings.length, 1); - assert.deepStrictEqual(second.surface.warnings, []); - first.notifier.dispose(); - second.notifier.dispose(); - }); - - test('suppression read failures stay silent and use a bounded retry', async () => { - let now = 0; - let readCount = 0; - const suppressionStore: OutdatedCliSuppressionStore = { - readAll: async () => { - readCount++; - throw new Error('storage unavailable'); - }, - write: async () => { - throw new Error('Unexpected write.'); - }, - delete: async () => { - throw new Error('Unexpected delete.'); - }, - }; - const { notifier, versionProvider, surface } = createNotifier(() => now, suppressionStore); - - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - now = 30 * 1_000; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - now = 60 * 1_000; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - assert.strictEqual(readCount, 2); - assert.deepStrictEqual(versionProvider.identityCalls, []); - assert.deepStrictEqual(surface.warnings, []); - notifier.dispose(); - }); - - test('final suppression read failure retries an available update after storage backoff', async () => { - let now = 0; - let readCount = 0; - const suppressionStore: OutdatedCliSuppressionStore = { - readAll: async () => { - readCount++; - if (readCount === 2) { - throw new Error('storage unavailable'); - } - return []; - }, - write: async () => { - throw new Error('Unexpected write.'); - }, - delete: async () => { - throw new Error('Unexpected delete.'); - }, - }; - const { notifier, versionProvider, surface } = createNotifier(() => now, suppressionStore); - - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - assert.deepStrictEqual(surface.warnings, []); - now = 30 * 1_000; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - now = 60 * 1_000; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - assert.strictEqual(readCount, 4); - assert.strictEqual(versionProvider.recommendationCalls.length, 2); - assert.strictEqual(surface.warnings.length, 1); - notifier.dispose(); - }); - - test("Don't Show Again remains effective for the session when persistence fails", async () => { - let now = 0; - const suppressionStore: OutdatedCliSuppressionStore = { - readAll: async () => [], - write: async () => { - throw new Error('storage unavailable'); - }, - delete: async () => { - throw new Error('Unexpected delete.'); - }, - }; - const { notifier, versionProvider, surface } = createNotifier(() => now, suppressionStore); - surface.selection = strings.dontShowAgainLabel; - - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - now = 5 * 60 * 1_000; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - assert.strictEqual(surface.warnings.length, 1); - assert.strictEqual(versionProvider.recommendationCalls.length, 1); - notifier.dispose(); - }); - - test("Don't Show Again evicts the oldest persisted suppression", async () => { - let now = 0; - const values = new Map(); - const { notifier, versionProvider, surface } = createNotifier( - () => now, - createSuppressionStore(values)); - surface.selection = strings.dontShowAgainLabel; - - for (let index = 0; index <= 100; index++) { - const cliPath = `/cli/${index}/aspire`; - versionProvider.identity = { - cliPath, - version: '13.5.0', - }; - await notifier.notifyIfOutdated(windowCliPathTarget, cliPath); - now++; - } - - assert.strictEqual(values.size, 100); - assert.strictEqual([...values.values()].some(value => value.suppressedAt === 0), false); - assert.strictEqual([...values.values()].some(value => value.suppressedAt === 100), true); - notifier.dispose(); - }); - - test("Don't Show Again persists concurrent suppressions independently across windows", async () => { - const values = new Map(); - const pendingWrites: Array<{ complete: () => void }> = []; - const createDelayedStore = (): OutdatedCliSuppressionStore => ({ - readAll: async () => [...values].map(([storageKey, value]) => ({ storageKey, ...value })), - write: (notificationKey, suppressedAt) => { - const storageKey = `suppression-${inMemoryStorageSequence++}`; - const suppression = { notificationKey, storageKey, suppressedAt }; - return new Promise(resolve => pendingWrites.push({ - complete: () => { - values.set(storageKey, { notificationKey, suppressedAt }); - resolve(suppression); - }, - })); - }, - delete: async storageKey => void values.delete(storageKey), - }); - const first = createNotifier(Date.now, createDelayedStore()); - const second = createNotifier(Date.now, createDelayedStore()); - first.surface.selection = strings.dontShowAgainLabel; - second.surface.selection = strings.dontShowAgainLabel; - second.versionProvider.identity = { - cliPath: '/other/aspire', - version: '13.5.0', - }; - - const suppressions = [ - first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'), - second.notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire'), - ]; - await waitFor(() => pendingWrites.length === 2, 'Expected independent suppression writes.'); - for (const write of pendingWrites.splice(0)) { - write.complete(); - } - await Promise.all(suppressions); - first.notifier.dispose(); - second.notifier.dispose(); - - const third = createNotifier(Date.now, createSuppressionStore(values)); - await third.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - third.versionProvider.identity = { - cliPath: '/other/aspire', - version: '13.5.0', - }; - await third.notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire'); - - assert.deepStrictEqual(third.surface.warnings, []); - assert.deepStrictEqual(third.versionProvider.recommendationCalls, []); - third.notifier.dispose(); - }); - - test("Don't Show Again compaction cannot delete a concurrent refresh", async () => { - let now = 0; - const values = new Map(); - let blockNextDeletion = false; - let blockedDeletion: { complete: () => void } | undefined; - const createBlockingStore = (): OutdatedCliSuppressionStore => ({ - readAll: async () => [...values].map(([storageKey, value]) => ({ storageKey, ...value })), - write: async (notificationKey, suppressedAt) => { - const storageKey = `suppression-${inMemoryStorageSequence++}`; - const suppression = { notificationKey, storageKey, suppressedAt }; - values.set(storageKey, { notificationKey, suppressedAt }); - return suppression; - }, - delete: storageKey => { - if (blockNextDeletion) { - blockNextDeletion = false; - return new Promise(resolve => { - blockedDeletion = { - complete: () => { - values.delete(storageKey); - resolve(); - }, - }; - }); - } - - values.delete(storageKey); - return Promise.resolve(); - }, - }); - - const staleWindow = createNotifier(() => now, createBlockingStore()); - staleWindow.versionProvider.identity = { - cliPath: '/cli/0/aspire', - version: '13.5.0', - }; - let completeStaleSelection!: (selection: string | undefined) => void; - staleWindow.surface.selectionPromise = new Promise(resolve => completeStaleSelection = resolve); - const staleNotification = staleWindow.notifier.notifyIfOutdated( - windowCliPathTarget, - '/cli/0/aspire'); - await waitFor( - () => staleWindow.surface.warnings.length === 1, - 'Expected stale window warning to open.'); - - const seeder = createNotifier(() => now, createBlockingStore()); - seeder.surface.selection = strings.dontShowAgainLabel; - for (let index = 0; index < 100; index++) { - const cliPath = `/cli/${index}/aspire`; - seeder.versionProvider.identity = { - cliPath, - version: '13.5.0', - }; - await seeder.notifier.notifyIfOutdated(windowCliPathTarget, cliPath); - now++; - } - seeder.notifier.dispose(); - - blockNextDeletion = true; - const compactor = createNotifier(() => now, createBlockingStore()); - compactor.surface.selection = strings.dontShowAgainLabel; - compactor.versionProvider.identity = { - cliPath: '/cli/new/aspire', - version: '13.5.0', - }; - const compaction = compactor.notifier.notifyIfOutdated( - windowCliPathTarget, - '/cli/new/aspire'); - await waitFor(() => blockedDeletion !== undefined, 'Expected stale suppression deletion to block.'); - - now++; - completeStaleSelection(strings.dontShowAgainLabel); - await staleNotification; - blockedDeletion?.complete(); - await compaction; - - const freshWindow = createNotifier(() => now, createBlockingStore()); - freshWindow.versionProvider.identity = { - cliPath: '/cli/0/aspire', - version: '13.5.0', - }; - await freshWindow.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/0/aspire'); - - assert.strictEqual(values.size, 100); - assert.deepStrictEqual(freshWindow.surface.warnings, []); - assert.deepStrictEqual(freshWindow.versionProvider.recommendationCalls, []); - freshWindow.notifier.dispose(); - compactor.notifier.dispose(); - staleWindow.notifier.dispose(); - }); - - test("Don't Show Again on a stale warning does not suppress the replacement version", async () => { - let now = 0; - const { notifier, versionProvider, surface } = createNotifier( - () => now, - createSuppressionStore()); - let resolveOldSelection!: (selection: string | undefined) => void; - surface.selectionPromise = new Promise(resolve => resolveOldSelection = resolve); - - const oldWarning = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - await waitFor(() => surface.warnings.length === 1, 'Expected the old CLI warning to open.'); - - now = 5 * 60 * 1_000; - versionProvider.identity = { - cliPath: '/cli/aspire', - version: '13.5.1', - }; - versionProvider.recommendation = { - status: 'available', - currentVersion: '13.5.1', - version: '13.6.0', - }; - surface.selectionPromise = undefined; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - resolveOldSelection(strings.dontShowAgainLabel); - await oldWarning; - - now += 6 * 60 * 60 * 1_000; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - assert.strictEqual(versionProvider.recommendationCalls.length, 3); - notifier.dispose(); - }); - - test('warns when stable 13.4.0 is behind stable 15.3.2', async () => { - const { notifier, versionProvider, surface } = createNotifier(); - versionProvider.identity = { - cliPath: '/cli/aspire', - version: '13.4.0', - }; - versionProvider.recommendation = { - status: 'available', - currentVersion: '13.4.0', - version: '15.3.2', - }; - - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - assert.strictEqual( - surface.warnings[0].message, - 'Aspire CLI 13.4.0 at /cli/aspire has a newer version available for its current channel: 15.3.2.'); - notifier.dispose(); }); test('uses five-minute version and six-hour update refresh intervals', async () => { @@ -525,12 +165,12 @@ suite('outdatedCliNotifier', () => { await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); now = 5 * 60 * 1_000 - 1; await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - assert.strictEqual(versionProvider.identityCalls.length, 1); + assert.strictEqual(versionProvider.versionCalls.length, 1); assert.strictEqual(versionProvider.recommendationCalls.length, 1); now++; await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - assert.strictEqual(versionProvider.identityCalls.length, 2); + assert.strictEqual(versionProvider.versionCalls.length, 2); assert.strictEqual(versionProvider.recommendationCalls.length, 1); now = 6 * 60 * 60 * 1_000; @@ -541,76 +181,12 @@ suite('outdatedCliNotifier', () => { }; await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - assert.strictEqual(versionProvider.identityCalls.length, 3); + assert.strictEqual(versionProvider.versionCalls.length, 3); assert.strictEqual(versionProvider.recommendationCalls.length, 2); assert.strictEqual(surface.warnings.length, 1); notifier.dispose(); }); - test('active-operation heartbeat rechecks five minutes from check start', async () => { - const clock = sinon.useFakeTimers({ now: 0 }); - const { notifier, versionProvider } = createNotifier(() => Date.now()); - versionProvider.recommendation = { - status: 'none', - currentVersion: '13.5.0', - }; - let resolveIdentity!: (identity: CliIdentityInfo | null) => void; - versionProvider.identityPromise = new Promise(resolve => resolveIdentity = resolve); - const heartbeatChecks: Promise[] = []; - const subscription = onDidResolveCliForOperation(resolution => { - heartbeatChecks.push(notifier.notifyIfOutdated(resolution.target, resolution.cliPath)); - }); - const heartbeat = startCliOperationResolutionHeartbeat( - windowCliPathTarget, - '/cli/aspire', - () => true); - - try { - const initialCheck = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - await clock.tickAsync(1_000); - resolveIdentity(versionProvider.identity); - await initialCheck; - versionProvider.identityPromise = undefined; - - await clock.tickAsync(5 * 60 * 1_000 - 1_000); - await Promise.all(heartbeatChecks); - - assert.strictEqual(versionProvider.identityCalls.length, 2); - } - finally { - heartbeat.dispose(); - subscription.dispose(); - notifier.dispose(); - clock.restore(); - } - }); - - test('active-operation heartbeat stops itself when the operation becomes inactive', async () => { - const clock = sinon.useFakeTimers(); - let active = true; - const resolutions: string[] = []; - const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); - const heartbeat = startCliOperationResolutionHeartbeat( - windowCliPathTarget, - '/cli/aspire', - () => active); - - try { - clock.tick(5 * 60 * 1_000); - assert.deepStrictEqual(resolutions, ['/cli/aspire']); - - active = false; - clock.tick(5 * 60 * 1_000); - assert.deepStrictEqual(resolutions, ['/cli/aspire']); - assert.strictEqual(clock.countTimers(), 0); - } - finally { - heartbeat.dispose(); - subscription.dispose(); - clock.restore(); - } - }); - test('samples version independently and caps unavailable doctor attempts per identity', async () => { let now = 0; const { notifier, versionProvider } = createNotifier(() => now); @@ -622,7 +198,7 @@ suite('outdatedCliNotifier', () => { await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); } - assert.strictEqual(versionProvider.identityCalls.length, 6); + assert.strictEqual(versionProvider.versionCalls.length, 6); assert.strictEqual(versionProvider.recommendationCalls.length, 3); now = 30 * 60 * 1_000; @@ -640,70 +216,10 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); - test('changed version or channel override resets update state', async () => { - let now = 0; - const { notifier, versionProvider } = createNotifier(() => now); - versionProvider.recommendation = { - status: 'none', - currentVersion: '13.5.0', - }; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - now = 5 * 60 * 1_000; - versionProvider.identity = { - cliPath: '/cli/aspire', - version: '13.5.1', - }; - versionProvider.recommendation = { - status: 'none', - currentVersion: '13.5.1', - }; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - now = 10 * 60 * 1_000; - versionProvider.identity = { - ...versionProvider.identity, - identityChannelOverride: 'daily', - }; - versionProvider.recommendation = { - status: 'none', - currentVersion: '13.5.1', - }; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - assert.strictEqual(versionProvider.recommendationCalls.length, 3); - assert.strictEqual(versionProvider.recommendationCalls[2]?.identityChannelOverride, 'daily'); - notifier.dispose(); - }); - - test('ineligible CLI never reruns doctor for the same identity', async () => { - let now = 0; - const { notifier, versionProvider, surface } = createNotifier(() => now); - versionProvider.recommendation = { - status: 'ineligible', - currentVersion: '13.6.0-dev', - }; - versionProvider.identity = { - cliPath: '/cli/aspire', - version: '13.6.0-dev', - }; - - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - now = 5 * 60 * 1_000; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - now = 24 * 60 * 60 * 1_000; - await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - - assert.strictEqual(versionProvider.identityCalls.length, 3); - assert.strictEqual(versionProvider.recommendationCalls.length, 1); - assert.deepStrictEqual(surface.warnings, []); - notifier.dispose(); - }); - test('coalesces same-path checks and serializes distinct doctors', async () => { const versionProvider = new FakeVersionProvider(); - versionProvider.getCliIdentity = async options => { - versionProvider.identityCalls.push(options); + versionProvider.getCliVersion = async options => { + versionProvider.versionCalls.push(options); return { cliPath: options?.cliPath ?? '/cli/aspire', version: '13.5.0', @@ -737,7 +253,7 @@ suite('outdatedCliNotifier', () => { releaseDoctors.shift()?.(); await Promise.all([...shared, distinct]); - assert.strictEqual(versionProvider.identityCalls.length, 2); + assert.strictEqual(versionProvider.versionCalls.length, 2); assert.strictEqual(versionProvider.recommendationCalls.length, 2); assert.strictEqual(maximumActiveDoctors, 1); notifier.dispose(); @@ -825,12 +341,11 @@ suite('outdatedCliNotifier', () => { } }); - test('does not warn when physical and effective versions disagree', async () => { + test('does not warn when the version probe and Doctor disagree', async () => { const { notifier, versionProvider, surface } = createNotifier(); versionProvider.identity = { cliPath: '/cli/aspire', version: '13.7.0-preview.1', - identityChannelOverride: 'daily', }; versionProvider.recommendation = { status: 'available', @@ -868,40 +383,25 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); - test('dispose cancels queued and active probes and suppresses warning actions', async () => { - const { notifier, versionProvider, surface } = createNotifier(); - let resolveIdentity!: (identity: CliIdentityInfo | null) => void; - versionProvider.identityPromise = new Promise(resolve => resolveIdentity = resolve); - - const active = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - await waitFor(() => versionProvider.identityCalls.length === 1, 'Expected identity probe to start.'); - const cancellationToken = versionProvider.identityCalls[0]?.cancellationToken; - notifier.dispose(); - resolveIdentity(versionProvider.identity); - await active; - - assert.strictEqual(cancellationToken?.isCancellationRequested, true); - assert.deepStrictEqual(surface.warnings, []); - assert.deepStrictEqual(surface.commands, []); - }); - - test('dispose prevents queued version work from starting', async () => { + test('dispose cancels an active probe and suppresses continuations', async () => { const versionProvider = new FakeVersionProvider(); - const resolveIdentities: Array<(identity: CliIdentityInfo | null) => void> = []; - versionProvider.getCliIdentity = async options => { - versionProvider.identityCalls.push(options); - return await new Promise(resolve => resolveIdentities.push(resolve)); + let resolveVersion!: (version: CliVersionInfo | null) => void; + versionProvider.getCliVersion = async options => { + versionProvider.versionCalls.push(options); + return await new Promise(resolve => resolveVersion = resolve); }; - const notifier = new OutdatedCliNotifier(versionProvider, new FakeSurface()); - const notifications = Array.from({ length: 5 }, (_, index) => - notifier.notifyIfOutdated(windowCliPathTarget, `/cli/${index}/aspire`)); - await waitFor(() => versionProvider.identityCalls.length === 4, 'Expected the version pool to fill.'); + const surface = new FakeSurface(); + const notifier = new OutdatedCliNotifier(versionProvider, surface); + const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await waitFor(() => versionProvider.versionCalls.length === 1, 'Expected the version probe to start.'); notifier.dispose(); - resolveIdentities.forEach(resolve => resolve(null)); - await Promise.all(notifications); + resolveVersion(null); + await notification; - assert.strictEqual(versionProvider.identityCalls.length, 4); + assert.strictEqual(versionProvider.versionCalls[0]?.cancellationToken?.isCancellationRequested, true); assert.deepStrictEqual(versionProvider.recommendationCalls, []); + assert.deepStrictEqual(surface.warnings, []); + assert.deepStrictEqual(surface.commands, []); }); }); diff --git a/extension/src/test/outdatedCliSuppressionStore.test.ts b/extension/src/test/outdatedCliSuppressionStore.test.ts deleted file mode 100644 index 36bbd184f37..00000000000 --- a/extension/src/test/outdatedCliSuppressionStore.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as assert from 'assert'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { FileSystemOutdatedCliSuppressionStore } from '../utils/outdatedCliSuppressionStore'; - -suite('outdatedCliSuppressionStore', () => { - let directoryPath: string; - - setup(() => { - directoryPath = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-suppressions-')); - }); - - teardown(() => { - fs.rmSync(directoryPath, { recursive: true, force: true }); - }); - - test('shares immutable generations across store instances', async () => { - const first = new FileSystemOutdatedCliSuppressionStore(directoryPath); - const second = new FileSystemOutdatedCliSuppressionStore(directoryPath); - - const [firstSuppression, secondSuppression] = await Promise.all([ - first.write('/cli/a\u000013.5.0', 1), - second.write('/cli/b\u000013.5.0', 2), - ]); - - assert.notStrictEqual(firstSuppression.storageKey, secondSuppression.storageKey); - assert.deepStrictEqual( - (await first.readAll()) - .map(({ notificationKey, suppressedAt }) => ({ notificationKey, suppressedAt })) - .sort((left, right) => left.suppressedAt - right.suppressedAt), - [ - { notificationKey: '/cli/a\u000013.5.0', suppressedAt: 1 }, - { notificationKey: '/cli/b\u000013.5.0', suppressedAt: 2 }, - ]); - - await first.delete(firstSuppression.storageKey); - - assert.deepStrictEqual( - (await second.readAll()).map(({ notificationKey }) => notificationKey), - ['/cli/b\u000013.5.0']); - }); - - test('creates missing storage and ignores incomplete or malformed entries', async () => { - const missingDirectory = path.join(directoryPath, 'missing'); - const store = new FileSystemOutdatedCliSuppressionStore(missingDirectory); - - assert.deepStrictEqual(await store.readAll(), []); - fs.writeFileSync( - path.join(missingDirectory, '.outdated-cli-suppression-incomplete.json.tmp'), - '{"notificationKey":"incomplete"}'); - fs.writeFileSync( - path.join(missingDirectory, 'outdated-cli-suppression-malformed.json'), - '{'); - fs.writeFileSync( - path.join(missingDirectory, 'outdated-cli-suppression-oversized.json'), - 'x'.repeat(256 * 1024 + 1)); - const valid = await store.write('/cli/valid\u000013.5.0', 3); - - assert.deepStrictEqual( - (await store.readAll()).map(({ notificationKey }) => notificationKey), - ['/cli/valid\u000013.5.0']); - await store.delete('outdated-cli-suppression-missing.json'); - await store.delete(valid.storageKey); - assert.deepStrictEqual(await store.readAll(), []); - }); -}); diff --git a/extension/src/test/registerCliCommands.test.ts b/extension/src/test/registerCliCommands.test.ts index 19eb7d0e6ad..f1065ed08dd 100644 --- a/extension/src/test/registerCliCommands.test.ts +++ b/extension/src/test/registerCliCommands.test.ts @@ -11,9 +11,8 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvid import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import * as cliPathModule from '../utils/cliPath'; import { ConfigInfoProvider } from '../utils/configInfoProvider'; -import { CliPathResolutionTarget, windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { CommandInvocationEvent, onDidInvokeCommand } from '../utils/telemetry'; -import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; import { createWorkspaceFolder, removeDirectorySafely } from './testHelpers'; suite('registerCliCommands', () => { let sandbox: sinon.SinonSandbox; @@ -21,6 +20,7 @@ suite('registerCliCommands', () => { let terminalProvider: AspireTerminalProvider; let sendCommandStub: sinon.SinonStub; let getTerminalStub: sinon.SinonStub; + let getAspireCliExecutablePathStub: sinon.SinonStub; let resolveCliPathStub: sinon.SinonStub; let showWorkspaceFolderPickStub: sinon.SinonStub; let workspaceFoldersStub: sinon.SinonStub; @@ -47,9 +47,11 @@ suite('registerCliCommands', () => { terminal: { show: sinon.stub() }, dispose: () => { }, }); + getAspireCliExecutablePathStub = sinon.stub().resolves('/resolved/aspire'); terminalProvider = { sendAspireCommandToAspireTerminal: sendCommandStub, getAspireTerminal: getTerminalStub, + getAspireCliExecutablePath: getAspireCliExecutablePathStub, } as unknown as AspireTerminalProvider; resolveCliPathStub = sandbox.stub(cliPathModule, 'resolveCliPath').resolves({ cliPath: '/resolved/aspire', @@ -295,48 +297,13 @@ suite('registerCliCommands', () => { assert.ok(tryExecuteDoAppHostStub.calledOnceWith(false, undefined, appHostPath, target, '/resolved/aspire')); }); - test('deploy and publish warn for the same multi-root CLI they launch', async () => { - const folderA = createWorkspaceFolder('a', '/repo/a'); - const folderB = createWorkspaceFolder('b', '/repo/b'); - const appHostPath = '/repo/b/AppHost/AppHost.csproj'; - const target = workspaceFolderCliPathTarget(folderB); - workspaceFoldersStub.value([folderA, folderB]); - getAppHostPathStub.resolves(appHostPath); - getWorkspaceFolderStub.returns(folderB); - resolveCliPathStub.callsFake(async resolvedTarget => ({ - cliPath: resolvedTarget.kind === 'workspaceFolder' && - resolvedTarget.workspaceFolder.uri.toString() === folderB.uri.toString() - ? '/repo/b/.aspire/bin/aspire' - : '/global/aspire', - available: true, - source: 'configured', - })); - const resolutions: Array<{ target: CliPathResolutionTarget; cliPath: string }> = []; - const resolutionSubscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); - - try { - await callbacks.get('aspire-vscode.deploy')!(); - await callbacks.get('aspire-vscode.publish')!(); - } - finally { - resolutionSubscription.dispose(); - } + test('deploy and publish defer CLI resolution to the AppHost launch', async () => { + await callbacks.get('aspire-vscode.deploy')!(); + await callbacks.get('aspire-vscode.publish')!(); - assert.deepStrictEqual(resolutions, [ - { target, cliPath: '/repo/b/.aspire/bin/aspire' }, - { target, cliPath: '/repo/b/.aspire/bin/aspire' }, - ]); - assert.ok(tryExecuteDeployAppHostStub.calledOnceWith( - false, - appHostPath, - target, - '/repo/b/.aspire/bin/aspire')); - assert.ok(tryExecutePublishAppHostStub.calledOnceWith( - false, - appHostPath, - target, - '/repo/b/.aspire/bin/aspire')); - assert.strictEqual(resolveCliPathStub.neverCalledWith(windowCliPathTarget), true); + assert.ok(tryExecuteDeployAppHostStub.calledOnceWithExactly(false)); + assert.ok(tryExecutePublishAppHostStub.calledOnceWithExactly(false)); + assert.strictEqual(resolveCliPathStub.called, false); }); test('do rejects a missing AppHost before probing the CLI', async () => { @@ -391,22 +358,15 @@ suite('registerCliCommands', () => { test('update self remains window scoped without a CLI availability gate', async () => { workspaceFoldersStub.value([createWorkspaceFolder('a', '/repo/a')]); - const resolutions: unknown[] = []; - const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); - try { - await callbacks.get('aspire-vscode.updateSelf')!(); - } - finally { - subscription.dispose(); - } + await callbacks.get('aspire-vscode.updateSelf')!(); assert.strictEqual(resolveCliPathStub.called, false); + assert.ok(getAspireCliExecutablePathStub.calledOnceWith(windowCliPathTarget)); assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { target: windowCliPathTarget, - reportCliResolution: false, + cliPath: '/resolved/aspire', })); - assert.deepStrictEqual(resolutions, []); }); test('update self uses the pre-resolved workspace CLI supplied by the warning action', async () => { @@ -416,10 +376,10 @@ suite('registerCliCommands', () => { await callbacks.get('aspire-vscode.updateSelf')!(target, '/repo/a/.aspire/bin/aspire'); assert.strictEqual(resolveCliPathStub.called, false); + assert.strictEqual(getAspireCliExecutablePathStub.called, false); assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { target, cliPath: '/repo/a/.aspire/bin/aspire', - reportCliResolution: false, })); }); }); \ No newline at end of file diff --git a/extension/src/test/resourceCommandArguments.test.ts b/extension/src/test/resourceCommandArguments.test.ts index 96398d7cda9..d6d758c59b6 100644 --- a/extension/src/test/resourceCommandArguments.test.ts +++ b/extension/src/test/resourceCommandArguments.test.ts @@ -16,7 +16,6 @@ import { createResourceCommandArgumentLoader } from '../views/ResourceCommandArg import { ResourceCommandArgumentInputJson } from '../data/AppHostDataRepository'; import { extensionLogOutputChannel } from '../utils/logging'; import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; -import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; function makeInput(overrides: Partial = {}): ResourceCommandArgumentInputJson { return { @@ -458,8 +457,6 @@ suite('ResourceCommandArguments', () => { return { stdin: { end: () => { } } } as any; }); - const resolutions: string[] = []; - const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); try { const loader = createResourceCommandArgumentLoader({ @@ -472,10 +469,8 @@ suite('ResourceCommandArguments', () => { await loader([]); assert.ok(getAspireCliExecutablePathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); - assert.deepStrictEqual(resolutions, ['/repo/bin/aspire']); } finally { - subscription.dispose(); spawnStub.restore(); withProgressStub.restore(); getWorkspaceFolderStub.restore(); diff --git a/extension/src/utils/AspirePackageRestoreProvider.ts b/extension/src/utils/AspirePackageRestoreProvider.ts index bebb07c27b6..6ce30e3e9f7 100644 --- a/extension/src/utils/AspirePackageRestoreProvider.ts +++ b/extension/src/utils/AspirePackageRestoreProvider.ts @@ -6,7 +6,6 @@ import { ChildProcessWithoutNullStreams } from 'child_process'; import { spawnCliProcess } from './process/cliProcess'; import { AspireTerminalProvider } from './AspireTerminalProvider'; import { getCliPathTargetForUri } from './cliPathVariables'; -import { reportCliResolvedForOperation } from './cliOperationResolution'; import { extensionLogOutputChannel } from './logging'; import { getEnableAutoRestore } from './settings'; import { runningAspireRestore, runningAspireRestoreProgress, aspireRestoreCompleted, aspireRestoreAllCompleted, aspireRestoreFailed, aspireRestoreFailedStatusBar } from '../loc/strings'; @@ -196,12 +195,10 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { this._showProgress(); try { - const target = getCliPathTargetForUri(uri); - const cliPath = await this._terminalProvider.getAspireCliExecutablePath(target); + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(getCliPathTargetForUri(uri)); if (this._disposed) { return; } - reportCliResolvedForOperation(target, cliPath); await new Promise((resolve, reject) => { let settled = false; const proc = spawnCliProcess(this._terminalProvider, cliPath, ['restore'], { diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts index dd510f12db3..fe1f6d41b9a 100644 --- a/extension/src/utils/AspireTerminalProvider.ts +++ b/extension/src/utils/AspireTerminalProvider.ts @@ -33,7 +33,6 @@ export interface SendAspireCommandOptions { terminalTarget?: 'shared' | 'editor'; target: CliPathResolutionTarget; cliPath?: string; - reportCliResolution?: boolean; } // String parts are fixed CLI syntax and are validated before interpolation. @@ -172,7 +171,7 @@ export class AspireTerminalProvider implements vscode.Disposable { const target = options?.target ?? windowCliPathTarget; const resolvedForThisCommand = options?.cliPath === undefined; const cliPath = options?.cliPath ?? await this.getAspireCliExecutablePath(target); - if (resolvedForThisCommand && options?.reportCliResolution !== false) { + if (resolvedForThisCommand) { reportCliResolvedForOperation(target, cliPath); } const subcommandLine = formatSubcommand(subcommand); diff --git a/extension/src/utils/appHostDiscovery.ts b/extension/src/utils/appHostDiscovery.ts index bfe895c1688..27ebaa7718a 100644 --- a/extension/src/utils/appHostDiscovery.ts +++ b/extension/src/utils/appHostDiscovery.ts @@ -47,12 +47,6 @@ interface CachedAppHostDiscovery { completed: boolean; started: boolean; stale: boolean; - resolvedCliPath?: string; -} - -export interface AppHostDiscoveryCliResolution { - workspaceFolder: vscode.WorkspaceFolder; - cliPath: string; } interface CliProcessResult { @@ -66,7 +60,6 @@ export class AppHostDiscoveryService implements vscode.Disposable { private static readonly _streamingDiscoveryMaxRuntimeMs = 5 * 60 * 1000; private readonly _onDidChangeCandidates = new vscode.EventEmitter(); - private readonly _onDidResolveCli = new vscode.EventEmitter(); private readonly _cache = new Map(); private readonly _activeDiscoveries = new Set(); private readonly _watchers = new Map(); @@ -76,18 +69,12 @@ export class AppHostDiscoveryService implements vscode.Disposable { private readonly _configInfoProvider: ConfigInfoProvider; private _disposed = false; readonly onDidChangeCandidates = this._onDidChangeCandidates.event; - readonly onDidResolveCli = this._onDidResolveCli.event; constructor(private readonly _terminalProvider: AspireTerminalProvider, configInfoProvider?: ConfigInfoProvider) { this._configInfoProvider = configInfoProvider ?? new ConfigInfoProvider(_terminalProvider); } - async discover( - workspaceFolder: vscode.WorkspaceFolder, - forceRefresh = false, - cancellationToken?: vscode.CancellationToken, - onIncrementalCandidate: IncrementalCandidateCallback = () => { }, - ): Promise { + async discover(workspaceFolder: vscode.WorkspaceFolder, forceRefresh = false, cancellationToken?: vscode.CancellationToken, onIncrementalCandidate: IncrementalCandidateCallback = () => { }): Promise { this._throwIfDisposed(); throwIfCancellationRequested(cancellationToken); @@ -109,6 +96,7 @@ export class AppHostDiscoveryService implements vscode.Disposable { if (!cachedDiscovery) { cachedDiscovery = this._createCachedDiscovery(workspaceFolder, key, forceRefresh); } + const candidateProgressCallback = (candidate: CandidateAppHostDisplayInfo): void => { if (!cancellationToken?.isCancellationRequested) { notifyCandidateProgressCallback(onIncrementalCandidate, candidate); @@ -131,10 +119,6 @@ export class AppHostDiscoveryService implements vscode.Disposable { } } - getResolvedCliPath(workspaceFolder: vscode.WorkspaceFolder): string | undefined { - return this._cache.get(path.resolve(workspaceFolder.uri.fsPath))?.resolvedCliPath; - } - private _createCachedDiscovery( workspaceFolder: vscode.WorkspaceFolder, key: string, @@ -165,17 +149,7 @@ export class AppHostDiscoveryService implements vscode.Disposable { const startDiscovery = () => { cachedDiscovery.started = true; const startTime = Date.now(); - return this._discoverCore( - workspaceFolder, - reportCandidateProgress, - cancellationSource.token, - forceRefresh, - cliPath => { - if (this._cache.get(key) === cachedDiscovery) { - cachedDiscovery.resolvedCliPath = cliPath; - this._onDidResolveCli.fire({ workspaceFolder, cliPath }); - } - }) + return this._discoverCore(workspaceFolder, reportCandidateProgress, cancellationSource.token, forceRefresh) .then(async discovery => { let candidates = discovery.candidates; try { @@ -299,20 +273,12 @@ export class AppHostDiscoveryService implements vscode.Disposable { this._cancelActiveCliProcesses.clear(); this._activeCliProcesses.clear(); this._onDidChangeCandidates.dispose(); - this._onDidResolveCli.dispose(); } - private async _discoverCore( - workspaceFolder: vscode.WorkspaceFolder, - reportCandidateProgress: IncrementalCandidateCallback, - cancellationToken: vscode.CancellationToken, - forceRefresh: boolean, - reportResolvedCliPath: (cliPath: string) => void, - ): Promise { + private async _discoverCore(workspaceFolder: vscode.WorkspaceFolder, reportCandidateProgress: IncrementalCandidateCallback, cancellationToken: vscode.CancellationToken, forceRefresh: boolean): Promise { let cliPath: string | undefined; try { cliPath = await this._getAspireCliExecutablePath(workspaceFolder, cancellationToken); - reportResolvedCliPath(cliPath); const lsJsonStreamSupported = await this._resolveLsStreamCapability(cliPath, workspaceFolder, forceRefresh); let appHosts: CandidateAppHostDisplayInfo[]; if (lsJsonStreamSupported) { diff --git a/extension/src/utils/cliOperationResolution.ts b/extension/src/utils/cliOperationResolution.ts index 96a04d9110a..8cc27899d3a 100644 --- a/extension/src/utils/cliOperationResolution.ts +++ b/extension/src/utils/cliOperationResolution.ts @@ -6,7 +6,6 @@ export interface CliOperationResolution { cliPath: string; } -const cliOperationResolutionRefreshIntervalMs = 5 * 60 * 1_000; const cliOperationResolutionEmitter = new vscode.EventEmitter(); /** @@ -18,29 +17,3 @@ export const onDidResolveCliForOperation = cliOperationResolutionEmitter.event; export function reportCliResolvedForOperation(target: CliPathResolutionTarget, cliPath: string): void { cliOperationResolutionEmitter.fire({ target, cliPath }); } - -/** - * Re-reports a CLI while its long-lived operation remains active so version-cache expiry can - * observe an executable replaced in place without probing paths whose operations have stopped. - */ -export function startCliOperationResolutionHeartbeat( - target: CliPathResolutionTarget, - cliPath: string, - isActive: () => boolean, -): vscode.Disposable { - let stopped = false; - const stop = () => { - if (!stopped) { - stopped = true; - clearInterval(timer); - } - }; - const timer = setInterval(() => { - if (!isActive()) { - stop(); - return; - } - reportCliResolvedForOperation(target, cliPath); - }, cliOperationResolutionRefreshIntervalMs); - return new vscode.Disposable(stop); -} diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index 3632b220b69..9cd532ebbe6 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -1,6 +1,4 @@ import * as vscode from 'vscode'; -import * as path from 'path'; -import { readFile, realpath, stat } from 'fs/promises'; import type { ChildProcessWithoutNullStreams } from 'child_process'; import { AspireTerminalProvider } from './AspireTerminalProvider'; import { spawnCliProcess, terminateCliProcess } from './process/cliProcess'; @@ -19,7 +17,6 @@ const cliVersionProbeTimeoutMs = 30_000; const cliUpdateProbeTimeoutMs = 130_000; const maxCliVersionOutputLength = 128; const maxCliUpdateOutputLength = 1024 * 1024; -const maxCliIdentitySidecarLength = 64 * 1024; const cliVersionPattern = /^(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,4})(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; type RawFeatureInfo = Partial & { @@ -76,7 +73,6 @@ export interface CliVersionStatusOptions { } export type CliUpdateRecommendationOptions = Omit & { - identityChannelOverride?: string; /** Captured Doctor working directory, when the caller must keep cache and process context aligned. */ workingDirectory?: string; }; @@ -86,14 +82,6 @@ export interface CliVersionInfo { version: string; } -export interface CliIdentityInfo extends CliVersionInfo { - identityChannelOverride?: string; -} - -type CliIdentityChannelOverrideResult = - | { status: 'available'; value?: string } - | { status: 'unavailable' }; - export type CliUpdateRecommendation = | { status: 'available'; currentVersion: string; version: string } | { status: 'none'; currentVersion: string } @@ -113,6 +101,23 @@ interface CliVersion { prereleaseIdentifiers: string[]; } +interface CliCaptureResult { + stdout: string; + stderr: string; + exitCode: number | null | undefined; +} + +interface CliCaptureOptions { + timeoutMs: number; + maxStdoutLength: number; + maxStderrLength?: number; + workingDirectory?: string; + env?: { name: string; value: string }[]; + description: string; + failureMessage: string; + cancellationToken?: vscode.CancellationToken; +} + /** * Working directory for `aspire config info`, chosen from the resolution target. * @@ -259,37 +264,6 @@ export class ConfigInfoProvider { return version ? { cliPath, version: version.value } : null; } - /** - * Probes the effective CLI version and samples any channel override that the CLI resolves ahead - * of its assembly identity: inherited environment first, then the install sidecar. - */ - async getCliIdentity(options?: CliVersionStatusOptions): Promise { - const startTime = Date.now(); - const timeoutMs = Math.min(options?.timeoutMs ?? cliVersionProbeTimeoutMs, cliVersionProbeTimeoutMs); - const cli = await this.getCliVersion({ ...options, timeoutMs }); - if (!cli || options?.cancellationToken?.isCancellationRequested) { - return null; - } - - const remainingTimeoutMs = timeoutMs - (Date.now() - startTime); - if (remainingTimeoutMs <= 0) { - return null; - } - - const channelOverride = await this._getIdentityChannelOverride( - cli.cliPath, - remainingTimeoutMs, - options?.cancellationToken); - if (channelOverride.status === 'unavailable') { - return null; - } - - return { - ...cli, - identityChannelOverride: channelOverride.value, - }; - } - /** * Returns a same-lane update recommended by the resolved CLI's structured update check. Stable * installations follow stable releases and prerelease installations follow prerelease @@ -310,7 +284,6 @@ export class ConfigInfoProvider { return await this._fetchCliUpdateRecommendation( cliPath, workingDirectory, - options?.identityChannelOverride, options?.cancellationToken); } @@ -362,91 +335,6 @@ export class ConfigInfoProvider { ?? 'unavailable'; } - private async _getIdentityChannelOverride( - cliPath: string, - timeoutMs: number, - cancellationToken?: vscode.CancellationToken, - ): Promise { - if (cancellationToken?.isCancellationRequested) { - return { status: 'unavailable' }; - } - - const environment = this._terminalProvider.createEnvironment(undefined, undefined, true, cliPath) as Record; - const environmentChannel = getEnvironmentValue(environment, 'ASPIRE_CLI_CHANNEL'); - if (environmentChannel) { - return { status: 'available', value: environmentChannel.toLowerCase() }; - } - - const abortController = new AbortController(); - let timeout: ReturnType | undefined; - let cancellation: vscode.Disposable | undefined; - let completeUnavailable!: () => void; - const unavailable = new Promise(resolve => { - completeUnavailable = () => resolve({ status: 'unavailable' }); - }); - const abort = () => { - abortController.abort(); - completeUnavailable(); - }; - timeout = setTimeout(abort, timeoutMs); - cancellation = cancellationToken?.onCancellationRequested(abort); - - const readOverride = async (): Promise => { - try { - // Install routes write: - // /.aspire-install.json - // { "source": "script", "channel": "stable", ... } - // Identity resolution uses this channel after the environment and before assembly - // metadata. Unknown or malformed sidecars fall back to the physical doctor identity. - let physicalCliPath = cliPath; - try { - physicalCliPath = await realpath(cliPath); - } - catch { - // Keep the invocation path when it cannot be canonicalized. The CLI itself uses - // the same fallback when resolving its process path. - } - if (abortController.signal.aborted) { - return { status: 'unavailable' }; - } - - const sidecarPath = path.join(path.dirname(physicalCliPath), '.aspire-install.json'); - if ((await stat(sidecarPath)).size > maxCliIdentitySidecarLength) { - return { status: 'available' }; - } - if (abortController.signal.aborted) { - return { status: 'unavailable' }; - } - - const sidecar = JSON.parse(await readFile(sidecarPath, { - encoding: 'utf8', - signal: abortController.signal, - })) as { channel?: unknown }; - return { - status: 'available', - value: typeof sidecar.channel === 'string' && sidecar.channel.length > 0 - ? sidecar.channel.toLowerCase() - : undefined, - }; - } - catch { - return abortController.signal.aborted - ? { status: 'unavailable' } - : { status: 'available' }; - } - }; - - try { - return await Promise.race([readOverride(), unavailable]); - } - finally { - if (timeout) { - clearTimeout(timeout); - } - cancellation?.dispose(); - } - } - private async _getCliVersionStatus( cliPath: string, minimumVersion: CliVersion, @@ -465,20 +353,80 @@ export class ConfigInfoProvider { }; } - private _probeCliVersion( + private async _probeCliVersion( cliPath: string, timeoutMs: number, cancellationToken?: vscode.CancellationToken, ): Promise { - return new Promise((resolve) => { + const result = await this._runCliCapture(cliPath, ['--version'], { + timeoutMs: Math.min(timeoutMs, cliVersionProbeTimeoutMs), + maxStdoutLength: maxCliVersionOutputLength, + description: 'Aspire CLI version probe', + failureMessage: 'Unable to probe Aspire CLI version', + cancellationToken, + }); + return result?.exitCode === 0 ? parseCliVersion(result.stdout) ?? null : null; + } + + private async _fetchCliUpdateRecommendation( + cliPath: string, + workingDirectory: string, + cancellationToken?: vscode.CancellationToken, + ): Promise { + const deadline = Date.now() + cliUpdateProbeTimeoutMs; + let args = ['doctor', '--format', 'json', noLogoOption]; + + for (let attempt = 0; attempt < 2; attempt++) { + const result = await this._runCliCapture(cliPath, args, { + timeoutMs: deadline - Date.now(), + maxStdoutLength: maxCliUpdateOutputLength, + maxStderrLength: maxCliUpdateOutputLength, + workingDirectory, + env: nonInteractiveCliEnvironment, + description: 'Aspire CLI update probe', + failureMessage: 'Unable to check for Aspire CLI updates', + cancellationToken, + }); + if (!result) { + return { status: 'unavailable' }; + } + + try { + const recommendation = parseCliUpdateRecommendationOutput(result.stdout); + if (recommendation.status !== 'unavailable' || + isStructuredDoctorOutput(result.stdout) || + !isNoLogoUnsupportedOutput(args, result.stdout, result.stderr)) { + return recommendation; + } + } + catch (error) { + if (!isNoLogoUnsupportedOutput(args, result.stdout, result.stderr)) { + extensionLogOutputChannel.warn(`Unable to parse Aspire CLI update status: ${String(error)}`); + return { status: 'unavailable' }; + } + } + + args = removeRootNoLogoOption(args); + } + + return { status: 'unavailable' }; + } + + private _runCliCapture( + cliPath: string, + args: string[], + options: CliCaptureOptions, + ): Promise { + return new Promise((resolve) => { let childProcess: ChildProcessWithoutNullStreams | undefined; let termination: Promise | undefined; - let output = ''; - let outputTooLong = false; + let stdout = ''; + let stderr = ''; + let stdoutTooLong = false; let settled = false; let timeout: ReturnType | undefined; let cancellation: vscode.Disposable | undefined; - const settle = (result: CliVersion | null) => { + const settle = (result: CliCaptureResult | null) => { if (settled) { return; } @@ -495,10 +443,10 @@ export class ConfigInfoProvider { return; } - extensionLogOutputChannel.warn(`Unable to probe Aspire CLI version: ${String(error)}`); + extensionLogOutputChannel.warn(`${options.failureMessage}: ${String(error)}`); settle(null); }; - const terminateAndSettle = (description: string) => { + const terminateAndSettle = (reason: 'timed-out' | 'cancelled') => { if (settled || termination) { return; } @@ -507,52 +455,51 @@ export class ConfigInfoProvider { return; } + const description = `${reason} ${options.description}`; termination = terminateCliProcess(childProcess, description) .catch(error => extensionLogOutputChannel.error(`Failed to terminate ${description}: ${String(error)}`)) .then(() => settle(null)); }; - if (cancellationToken?.isCancellationRequested) { + if (options.timeoutMs <= 0 || options.cancellationToken?.isCancellationRequested) { settle(null); return; } timeout = setTimeout(() => { - terminateAndSettle('timed-out Aspire CLI version probe'); - }, Math.min(timeoutMs, cliVersionProbeTimeoutMs)); - cancellation = cancellationToken?.onCancellationRequested(() => { - terminateAndSettle('cancelled Aspire CLI version probe'); - }); + terminateAndSettle('timed-out'); + }, options.timeoutMs); + cancellation = options.cancellationToken?.onCancellationRequested(() => terminateAndSettle('cancelled')); try { - childProcess = spawnCliProcess(this._terminalProvider, cliPath, ['--version'], { + childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { createProcessGroup: true, + workingDirectory: options.workingDirectory, + env: options.env, + noExtensionVariables: true, stdoutCallback: value => { - if (output.length + value.length > maxCliVersionOutputLength) { - outputTooLong = true; + if (stdout.length + value.length > options.maxStdoutLength) { + stdoutTooLong = true; return; } - - output += value; + stdout += value; + }, + stderrCallback: value => { + const maxStderrLength = options.maxStderrLength ?? 0; + if (stderr.length < maxStderrLength) { + stderr += value.slice(0, maxStderrLength - stderr.length); + } }, exitCallback: code => { if (termination) { return; } - if (code !== 0 || outputTooLong) { - settle(null); - return; - } - - const version = parseCliVersion(output); - if (!version) { + if (stdoutTooLong) { settle(null); return; } - - settle(version); + settle({ stdout, stderr, exitCode: code }); }, errorCallback: reportUnavailable, - noExtensionVariables: true, }); } catch (error) { @@ -561,126 +508,6 @@ export class ConfigInfoProvider { }); } - private _fetchCliUpdateRecommendation( - cliPath: string, - workingDirectory: string, - identityChannelOverride: string | undefined, - cancellationToken?: vscode.CancellationToken, - ): Promise { - return new Promise(resolve => { - let childProcess: ChildProcessWithoutNullStreams | undefined; - let termination: Promise | undefined; - let settled = false; - let timeout: ReturnType | undefined; - let cancellation: vscode.Disposable | undefined; - const settle = (result: CliUpdateRecommendation) => { - if (settled) { - return; - } - - settled = true; - if (timeout) { - clearTimeout(timeout); - } - cancellation?.dispose(); - resolve(result); - }; - const reportUnavailable = (error: unknown) => { - if (settled || termination) { - return; - } - - extensionLogOutputChannel.warn(`Unable to check for Aspire CLI updates: ${String(error)}`); - settle({ status: 'unavailable' }); - }; - const terminateAndSettle = (description: string) => { - if (settled || termination) { - return; - } - if (!childProcess) { - settle({ status: 'unavailable' }); - return; - } - - termination = terminateCliProcess(childProcess, description) - .catch(error => extensionLogOutputChannel.error(`Failed to terminate ${description}: ${String(error)}`)) - .then(() => settle({ status: 'unavailable' })); - }; - if (cancellationToken?.isCancellationRequested) { - settle({ status: 'unavailable' }); - return; - } - - timeout = setTimeout(() => { - terminateAndSettle('timed-out Aspire CLI update probe'); - }, cliUpdateProbeTimeoutMs); - cancellation = cancellationToken?.onCancellationRequested(() => { - terminateAndSettle('cancelled Aspire CLI update probe'); - }); - - const runDoctor = (args: string[], allowNoLogoRetry: boolean) => { - let output = ''; - let stderr = ''; - let outputTooLong = false; - try { - childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { - createProcessGroup: true, - workingDirectory, - noExtensionVariables: true, - env: nonInteractiveCliEnvironment, - stdoutCallback: value => { - if (output.length + value.length > maxCliUpdateOutputLength) { - outputTooLong = true; - return; - } - output += value; - }, - stderrCallback: value => { - if (stderr.length < maxCliUpdateOutputLength) { - stderr += value.slice(0, maxCliUpdateOutputLength - stderr.length); - } - }, - exitCallback: () => { - if (settled || termination) { - return; - } - if (outputTooLong) { - settle({ status: 'unavailable' }); - return; - } - - try { - const recommendation = parseCliUpdateRecommendationOutput(output, identityChannelOverride); - if (recommendation.status !== 'unavailable' || - isStructuredDoctorOutput(output) || - !allowNoLogoRetry || - !isNoLogoUnsupportedOutput(args, output, stderr)) { - settle(recommendation); - return; - } - } - catch (error) { - if (!allowNoLogoRetry || !isNoLogoUnsupportedOutput(args, output, stderr)) { - extensionLogOutputChannel.warn(`Unable to parse Aspire CLI update status: ${String(error)}`); - settle({ status: 'unavailable' }); - return; - } - } - - runDoctor(removeRootNoLogoOption(args), false); - }, - errorCallback: reportUnavailable, - }); - } - catch (error) { - reportUnavailable(error); - } - }; - - runDoctor(['doctor', '--format', 'json', noLogoOption], true); - }); - } - private _startSharedProbe( cacheKey: string, cliPath: string, @@ -1005,17 +832,13 @@ export function compareCliVersionValues(left: string, right: string): number | u return leftVersion && rightVersion ? compareCliVersions(leftVersion, rightVersion) : undefined; } -export function parseCliUpdateRecommendationOutput( - output: string, - identityChannelOverride?: string, -): CliUpdateRecommendation { +export function parseCliUpdateRecommendationOutput(output: string): CliUpdateRecommendation { // `aspire doctor --format json` emits: // { "checks": [{ "name": "cli-version", "metadata": { // "currentVersion": "13.5.0", "latestVersion": "13.6.0", // "latestVersionChannel": "stable" } }] } - // Doctor reports the physical assembly channel. The optional override follows the CLI's own - // environment/sidecar precedence, but it is applied only after the physical binary is proven to - // be a published build. This keeps local/PR/run builds silent even when they emulate stable. + // Doctor reports the channel baked into the installed assembly. This keeps local/PR/run builds + // silent even when their process environment emulates a published channel. const response = JSON.parse(output.trim()) as { checks?: unknown }; if (!Array.isArray(response.checks)) { return { status: 'unavailable' }; @@ -1045,13 +868,7 @@ export function parseCliUpdateRecommendationOutput( return { status: 'unavailable' }; } - const physicalChannel = normalizePublishedCliChannel(metadata.identityChannel); - if (!physicalChannel) { - return { status: 'ineligible', currentVersion: currentVersion.value }; - } - const identityChannel = identityChannelOverride - ? normalizePublishedCliChannel(identityChannelOverride) - : physicalChannel; + const identityChannel = normalizePublishedCliChannel(metadata.identityChannel); if (!identityChannel) { return { status: 'ineligible', currentVersion: currentVersion.value }; } @@ -1129,18 +946,6 @@ function normalizeRecommendationChannel(value: unknown): 'stable' | 'prerelease' : undefined; } -function getEnvironmentValue( - environment: Record, - name: string, -): string | undefined { - if (process.platform !== 'win32') { - return environment[name]; - } - - const key = Object.keys(environment).find(candidate => candidate.toUpperCase() === name); - return key ? environment[key] : undefined; -} - export function parseConfigInfoOutput(output: string): ConfigInfo { const configInfo = JSON.parse(output.trim()) as RawConfigInfo; diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 6a3464948d3..bf73e3ff09e 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -2,7 +2,6 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as strings from '../loc/strings'; import { - CliIdentityInfo, CliUpdateRecommendation, CliVersionInfo, compareCliVersionValues, @@ -10,10 +9,6 @@ import { resolveConfigInfoWorkingDirectory, } from './configInfoProvider'; import { CliPathResolutionTarget, getCliPathTargetKey } from './cliPathVariables'; -import { - OutdatedCliSuppressionStore, - PersistedCliSuppression, -} from './outdatedCliSuppressionStore'; import { extensionLogOutputChannel } from './logging'; import { getComparisonKey } from './paths/comparison'; @@ -24,9 +19,9 @@ const completedUpdateRefreshIntervalMs = 6 * 60 * 60 * 1_000; const unavailableRetryBaseMs = 60 * 1_000; const unavailableRetryMaximumMs = 30 * 60 * 1_000; const maximumUnavailableAttemptsPerIdentity = 3; -const maximumPersistedSuppressions = 100; +const persistedSuppressionKey = 'outdatedCliNotification.suppressedCliVersions'; -type CliVersionProvider = Pick; +type CliVersionProvider = Pick; export interface OutdatedCliNotificationSurface { showWarning(message: string, ...actions: string[]): Thenable; @@ -34,17 +29,16 @@ export interface OutdatedCliNotificationSurface { } interface CliCheckState { - identity: CliIdentityInfo | undefined; + identity: CliVersionInfo | undefined; versionValidUntil: number; - updateStatus: 'complete' | 'ineligible' | 'suppressed' | 'unavailable' | undefined; + updateStatus: 'complete' | 'ineligible' | 'unavailable' | undefined; updateValidUntil: number; failureCount: number; } interface PendingNotification { - checkKey: string; target: CliPathResolutionTarget; - cli: CliIdentityInfo; + cli: CliVersionInfo; recommendedVersion: string; } @@ -63,19 +57,16 @@ export class OutdatedCliNotifier implements vscode.Disposable { private readonly _persistentlySuppressedCliVersions: Set; private readonly _inFlightByCheckKey = new Map>(); private readonly _cancellationSource = new vscode.CancellationTokenSource(); - private readonly _versionLimiter = new AsyncLimiter(4); - private readonly _doctorLimiter = new AsyncLimiter(1); - private _sharedSuppressionRefresh: Promise | undefined; - private _suppressionReadRetryAfter = 0; + private _doctorTail: Promise = Promise.resolve(); private _disposed = false; constructor( private readonly _versionProvider: CliVersionProvider, private readonly _surface: OutdatedCliNotificationSurface = defaultSurface, private readonly _now: () => number = Date.now, - private readonly _suppressionStore?: OutdatedCliSuppressionStore, + private readonly _globalState?: vscode.Memento, ) { - this._persistentlySuppressedCliVersions = new Set(); + this._persistentlySuppressedCliVersions = new Set(readPersistedSuppressions(_globalState)); } async notifyIfOutdated(target: CliPathResolutionTarget, cliPath: string): Promise { @@ -88,10 +79,6 @@ export class OutdatedCliNotifier implements vscode.Disposable { if ((this._stateByCheckKey.get(checkKey)?.versionValidUntil ?? 0) > this._now()) { return; } - if (!await this._refreshPersistedSuppressions(true) || this._disposed) { - return; - } - const existingProbe = this._inFlightByCheckKey.get(checkKey); if (existingProbe) { await existingProbe; @@ -116,15 +103,6 @@ export class OutdatedCliNotifier implements vscode.Disposable { return; } - // Another VS Code window can persist suppression while this window's probes are in flight. - const suppressionRefreshSucceeded = await this._refreshPersistedSuppressions(false); - if (this._disposed) { - return; - } - if (!suppressionRefreshSucceeded) { - this._invalidateRecommendationAfterSuppressionReadFailure(notification); - return; - } const notificationKey = getNotificationKey(notification.cli.cliPath, notification.cli.version); if (this._notifiedCliVersions.has(notificationKey) || this._persistentlySuppressedCliVersions.has(notificationKey)) { @@ -143,9 +121,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { return; } if (selection === strings.dontShowAgainLabel) { - await this._suppressNotification( - notificationKey, - notification.checkKey); + await this._suppressNotification(notificationKey); return; } if (selection !== strings.updateAspireCliAction) { @@ -153,83 +129,21 @@ export class OutdatedCliNotifier implements vscode.Disposable { } // This user-initiated guard intentionally bypasses the five-minute cache. - const currentVersionProbe = await this._versionLimiter.run(() => - this._versionProvider.getCliVersion({ - target: notification.target, - cliPath: notification.cli.cliPath, - cancellationToken: this._cancellationSource.token, - })); - if (!currentVersionProbe.executed || this._disposed) { + const currentVersionProbe = await this._versionProvider.getCliVersion({ + target: notification.target, + cliPath: notification.cli.cliPath, + cancellationToken: this._cancellationSource.token, + }); + if (this._disposed) { return; } - if (!currentVersionProbe.value || currentVersionProbe.value.version !== notification.cli.version) { + if (!currentVersionProbe || currentVersionProbe.version !== notification.cli.version) { return; } await this._surface.executeCommand(updateAspireCliCommand, notification.target, notification.cli.cliPath); } - private _invalidateRecommendationAfterSuppressionReadFailure(notification: PendingNotification): void { - const state = this._stateByCheckKey.get(notification.checkKey); - if (!state?.identity || - !areCliIdentitiesEqual(state.identity, notification.cli)) { - return; - } - - // The update is known to be available, but UI cannot be shown safely until all persisted - // suppressions are readable. Retry after the storage backoff instead of hiding it behind the - // normal six-hour successful-update cache. - state.versionValidUntil = this._suppressionReadRetryAfter; - state.updateStatus = undefined; - state.updateValidUntil = 0; - state.failureCount = 0; - } - - private async _refreshPersistedSuppressions(useSharedRefresh: boolean): Promise { - if (!this._suppressionStore) { - return true; - } - if (this._suppressionReadRetryAfter > this._now()) { - return false; - } - if (useSharedRefresh && this._sharedSuppressionRefresh) { - return await this._sharedSuppressionRefresh; - } - - const suppressionStore = this._suppressionStore; - let refresh!: Promise; - refresh = this._readPersistedSuppressions(suppressionStore).finally(() => { - if (this._sharedSuppressionRefresh === refresh) { - this._sharedSuppressionRefresh = undefined; - } - }); - if (useSharedRefresh) { - this._sharedSuppressionRefresh = refresh; - } - return await refresh; - } - - private async _readPersistedSuppressions( - suppressionStore: OutdatedCliSuppressionStore, - ): Promise { - try { - for (const persistedSuppression of await suppressionStore.readAll()) { - this._persistentlySuppressedCliVersions.add(persistedSuppression.notificationKey); - } - this._suppressionReadRetryAfter = 0; - return true; - } - catch (error) { - this._recordSuppressionReadFailure('Unable to read Aspire CLI warning suppressions', error); - return false; - } - } - - private _recordSuppressionReadFailure(message: string, error: unknown): void { - this._suppressionReadRetryAfter = this._now() + versionFailureRetryMs; - extensionLogOutputChannel.warn(`${message}: ${String(error)}`); - } - private async _checkForUpdate( target: CliPathResolutionTarget, checkKey: string, @@ -237,19 +151,18 @@ export class OutdatedCliNotifier implements vscode.Disposable { workingDirectory: string, checkStartedAt: number, ): Promise { - const versionProbe = await this._versionLimiter.run(() => - this._versionProvider.getCliIdentity({ - target, - cliPath, - cancellationToken: this._cancellationSource.token, - })); - if (!versionProbe.executed || this._disposed) { + const versionProbe = await this._versionProvider.getCliVersion({ + target, + cliPath, + cancellationToken: this._cancellationSource.token, + }); + if (this._disposed) { return undefined; } const now = this._now(); const previous = this._stateByCheckKey.get(checkKey); - const identity = versionProbe.value; + const identity = versionProbe; if (!identity) { this._stateByCheckKey.set(checkKey, { identity: previous?.identity, @@ -281,36 +194,26 @@ export class OutdatedCliNotifier implements vscode.Disposable { const notificationKey = getNotificationKey(identity.cliPath, identity.version); if (this._persistentlySuppressedCliVersions.has(notificationKey)) { - state.updateStatus = 'suppressed'; - state.updateValidUntil = Number.POSITIVE_INFINITY; - state.failureCount = 0; return undefined; } if (!identityChanged && (state.updateStatus === 'ineligible' || - state.updateStatus === 'suppressed' || (state.updateStatus !== undefined && state.updateValidUntil > now))) { return undefined; } - const serializedRecommendationProbe = await this._doctorLimiter.run(() => - this._versionLimiter.run(() => - this._versionProvider.getCliUpdateRecommendation({ - target, - cliPath, - identityChannelOverride: identity.identityChannelOverride, - workingDirectory, - cancellationToken: this._cancellationSource.token, - }))); - const recommendationProbe = serializedRecommendationProbe.executed - ? serializedRecommendationProbe.value - : undefined; - if (!recommendationProbe?.executed || this._disposed) { + const recommendation = await this._runDoctor(() => + this._versionProvider.getCliUpdateRecommendation({ + target, + cliPath, + workingDirectory, + cancellationToken: this._cancellationSource.token, + })); + if (recommendation === undefined || this._disposed) { return undefined; } - const recommendation = recommendationProbe.value; if (recommendation.status === 'ineligible') { state.updateStatus = 'ineligible'; state.updateValidUntil = Number.POSITIVE_INFINITY; @@ -332,10 +235,26 @@ export class OutdatedCliNotifier implements vscode.Disposable { const comparison = compareCliVersionValues(identity.version, recommendation.version); return comparison !== undefined && comparison < 0 - ? { checkKey, target, cli: identity, recommendedVersion: recommendation.version } + ? { target, cli: identity, recommendedVersion: recommendation.version } : undefined; } + private async _runDoctor( + action: () => Promise, + ): Promise { + const previous = this._doctorTail; + let release!: () => void; + this._doctorTail = new Promise(resolve => release = resolve); + await previous; + + try { + return this._disposed ? undefined : await action(); + } + finally { + release(); + } + } + private _recordUnavailable(state: CliCheckState): void { state.failureCount = state.updateStatus === 'unavailable' ? state.failureCount + 1 : 1; state.updateStatus = 'unavailable'; @@ -351,76 +270,16 @@ export class OutdatedCliNotifier implements vscode.Disposable { unavailableRetryMaximumMs); } - private async _suppressNotification( - notificationKey: string, - checkKey: string, - ): Promise { + private async _suppressNotification(notificationKey: string): Promise { this._persistentlySuppressedCliVersions.add(notificationKey); - const state = this._stateByCheckKey.get(checkKey); - if (state?.identity && - getNotificationKey(state.identity.cliPath, state.identity.version) === notificationKey) { - state.updateStatus = 'suppressed'; - state.updateValidUntil = Number.POSITIVE_INFINITY; - state.failureCount = 0; - } - - if (!this._suppressionStore) { - return; - } - - // Each VS Code window owns a notifier. Immutable generations in global storage let windows - // coordinate without relying on asynchronously synchronized Memento snapshots. - const suppressedAt = this._now(); try { - await this._suppressionStore.write(notificationKey, suppressedAt); + await this._globalState?.update( + persistedSuppressionKey, + [...this._persistentlySuppressedCliVersions]); } catch (error) { extensionLogOutputChannel.warn(`Unable to persist Aspire CLI warning suppression: ${String(error)}`); - return; - } - - let persistedSuppressions: PersistedCliSuppression[]; - try { - persistedSuppressions = await this._suppressionStore.readAll(); - } - catch (error) { - this._recordSuppressionReadFailure('Unable to compact Aspire CLI warning suppressions', error); - return; - } - for (const persistedSuppression of persistedSuppressions) { - this._persistentlySuppressedCliVersions.add(persistedSuppression.notificationKey); - } - - const latestSuppressionByNotification = new Map(); - const staleSuppressions: PersistedCliSuppression[] = []; - for (const suppression of persistedSuppressions) { - const previous = latestSuppressionByNotification.get(suppression.notificationKey); - if (!previous || comparePersistedSuppressions(previous, suppression) < 0) { - if (previous) { - staleSuppressions.push(previous); - } - latestSuppressionByNotification.set(suppression.notificationKey, suppression); - } else { - staleSuppressions.push(suppression); - } - } - - const excessCount = latestSuppressionByNotification.size - maximumPersistedSuppressions; - const cappedSuppressions = [...latestSuppressionByNotification.values()] - .filter(suppression => suppression.notificationKey !== notificationKey) - .sort(comparePersistedSuppressions) - .slice(0, Math.max(0, excessCount)); - for (const suppression of [...staleSuppressions, ...cappedSuppressions]) { - // Storage keys identify immutable generations. Deleting an observed generation cannot - // remove a newer suppression written concurrently by another VS Code window. - try { - await this._suppressionStore.delete(suppression.storageKey); - } - catch (error) { - extensionLogOutputChannel.warn( - `Unable to delete stale Aspire CLI warning suppression '${suppression.storageKey}': ${String(error)}`); - } } } @@ -432,8 +291,6 @@ export class OutdatedCliNotifier implements vscode.Disposable { this._disposed = true; this._cancellationSource.cancel(); this._cancellationSource.dispose(); - this._doctorLimiter.dispose(); - this._versionLimiter.dispose(); this._inFlightByCheckKey.clear(); this._stateByCheckKey.clear(); this._notifiedCliVersions.clear(); @@ -452,71 +309,13 @@ function getCliCheckKey( return `${getCliPathTargetKey(target)}\u0000${getComparisonKey(path.normalize(cliPath))}\u0000${getComparisonKey(path.normalize(workingDirectory))}`; } -function comparePersistedSuppressions(left: PersistedCliSuppression, right: PersistedCliSuppression): number { - return left.suppressedAt - right.suppressedAt || - (left.storageKey < right.storageKey ? -1 : left.storageKey > right.storageKey ? 1 : 0); +function readPersistedSuppressions(globalState: vscode.Memento | undefined): string[] { + const values = globalState?.get(persistedSuppressionKey); + return Array.isArray(values) + ? values.filter((value): value is string => typeof value === 'string') + : []; } -function areCliIdentitiesEqual(left: CliIdentityInfo | undefined, right: CliIdentityInfo): boolean { - return left?.version === right.version && - left.identityChannelOverride === right.identityChannelOverride; -} - -type LimiterResult = - | { executed: true; value: T } - | { executed: false }; - -class AsyncLimiter implements vscode.Disposable { - private readonly _waiters: Array<(acquired: boolean) => void> = []; - private _activeCount = 0; - private _disposed = false; - - constructor(private readonly _maximumConcurrency: number) { - } - - async run(action: () => Promise): Promise> { - if (!await this._acquire()) { - return { executed: false }; - } - if (this._disposed) { - this._release(); - return { executed: false }; - } - - try { - return { executed: true, value: await action() }; - } - finally { - this._release(); - } - } - - private async _acquire(): Promise { - if (this._disposed) { - return false; - } - if (this._activeCount < this._maximumConcurrency) { - this._activeCount++; - return true; - } - - return await new Promise(resolve => this._waiters.push(resolve)); - } - - private _release(): void { - const waiter = this._waiters.shift(); - if (waiter) { - waiter(true); - return; - } - - this._activeCount--; - } - - dispose(): void { - this._disposed = true; - while (this._waiters.length > 0) { - this._waiters.shift()?.(false); - } - } +function areCliIdentitiesEqual(left: CliVersionInfo | undefined, right: CliVersionInfo): boolean { + return left?.version === right.version; } diff --git a/extension/src/utils/outdatedCliSuppressionStore.ts b/extension/src/utils/outdatedCliSuppressionStore.ts deleted file mode 100644 index cb2b02b3dc0..00000000000 --- a/extension/src/utils/outdatedCliSuppressionStore.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'fs/promises'; -import * as path from 'path'; -import { extensionLogOutputChannel } from './logging'; - -const suppressionFilePrefix = 'outdated-cli-suppression-'; -const suppressionFileSuffix = '.json'; -const maximumSuppressionFileLength = 256 * 1024; -let suppressionFileSequence = 0; - -export interface PersistedCliSuppression { - notificationKey: string; - storageKey: string; - suppressedAt: number; -} - -export interface OutdatedCliSuppressionStore { - readAll(): Promise; - write(notificationKey: string, suppressedAt: number): Promise; - delete(storageKey: string): Promise; -} - -/** - * Uses one immutable file per suppression generation so separate VS Code extension hosts never - * overwrite one another's cached state object. Publishing by rename keeps readers from observing - * partially written JSON. - */ -export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppressionStore { - constructor(private readonly _directoryPath: string) { - } - - async readAll(): Promise { - await mkdir(this._directoryPath, { recursive: true }); - const entries = await readdir(this._directoryPath, { withFileTypes: true }); - const suppressions: PersistedCliSuppression[] = []; - - for (const entry of entries) { - if (!entry.isFile() || - !entry.name.startsWith(suppressionFilePrefix) || - !entry.name.endsWith(suppressionFileSuffix)) { - continue; - } - - const filePath = path.join(this._directoryPath, entry.name); - try { - if ((await stat(filePath)).size > maximumSuppressionFileLength) { - extensionLogOutputChannel.warn(`Ignoring oversized Aspire CLI warning suppression file '${entry.name}'.`); - continue; - } - - // Suppression files contain: - // { "notificationKey": "\\u0000", "suppressedAt": 1787880000000 } - const value = JSON.parse(await readFile(filePath, 'utf8')) as { - notificationKey?: unknown; - suppressedAt?: unknown; - }; - if (typeof value.notificationKey !== 'string' || - typeof value.suppressedAt !== 'number' || - !Number.isFinite(value.suppressedAt)) { - extensionLogOutputChannel.warn(`Ignoring malformed Aspire CLI warning suppression file '${entry.name}'.`); - continue; - } - - suppressions.push({ - notificationKey: value.notificationKey, - storageKey: entry.name, - suppressedAt: value.suppressedAt, - }); - } - catch (error) { - if (isFileNotFoundError(error)) { - continue; - } - if (error instanceof SyntaxError) { - extensionLogOutputChannel.warn(`Ignoring malformed Aspire CLI warning suppression file '${entry.name}'.`); - continue; - } - throw new Error( - `Unable to read Aspire CLI warning suppression file '${entry.name}': ${String(error)}`); - } - } - - return suppressions; - } - - async write(notificationKey: string, suppressedAt: number): Promise { - await mkdir(this._directoryPath, { recursive: true }); - const generation = `${suppressedAt}-${process.pid}-${suppressionFileSequence++}`; - const storageKey = `${suppressionFilePrefix}${generation}${suppressionFileSuffix}`; - const temporaryPath = path.join(this._directoryPath, `.${storageKey}.tmp`); - const storagePath = path.join(this._directoryPath, storageKey); - - await writeFile( - temporaryPath, - JSON.stringify({ notificationKey, suppressedAt }), - { encoding: 'utf8', flag: 'wx' }); - try { - await rename(temporaryPath, storagePath); - } - catch (error) { - await unlink(temporaryPath).catch(cleanupError => { - if (!isFileNotFoundError(cleanupError)) { - extensionLogOutputChannel.warn( - `Unable to remove temporary Aspire CLI warning suppression file: ${String(cleanupError)}`); - } - }); - throw error; - } - - return { notificationKey, storageKey, suppressedAt }; - } - - async delete(storageKey: string): Promise { - try { - await unlink(path.join(this._directoryPath, storageKey)); - } - catch (error) { - if (!isFileNotFoundError(error)) { - throw error; - } - } - } -} - -function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && - 'code' in error && - (error as NodeJS.ErrnoException).code === 'ENOENT'; -} diff --git a/extension/src/views/ResourceCommandArgumentsLoader.ts b/extension/src/views/ResourceCommandArgumentsLoader.ts index 663dddecaab..2b035e9b3ee 100644 --- a/extension/src/views/ResourceCommandArgumentsLoader.ts +++ b/extension/src/views/ResourceCommandArgumentsLoader.ts @@ -15,7 +15,6 @@ import { } from './ResourceCommandArguments'; import { nonInteractiveCliEnvironment } from '../utils/environment'; import { getCliPathTargetForUri } from '../utils/cliPathVariables'; -import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; export interface ResourceCommandArgumentLoaderContext { cliExecutionProvider: AspireTerminalProvider; @@ -48,9 +47,7 @@ async function loadResourceCommandArgumentInputs( { location: vscode.ProgressLocation.Window, title: resourceCommandLoadingDynamicInputs }, async () => { try { - const target = getCliPathTargetForUri(vscode.Uri.file(context.appHostPath!)); - const cliPath = await context.cliExecutionProvider.getAspireCliExecutablePath(target); - reportCliResolvedForOperation(target, cliPath); + const cliPath = await context.cliExecutionProvider.getAspireCliExecutablePath(getCliPathTargetForUri(vscode.Uri.file(context.appHostPath!))); const args = ['resource', context.resourceName, context.commandName, '--load-arguments', '--non-interactive', '--apphost', context.appHostPath!, noLogoOption]; args.push(...buildResourceCommandCliArgs(values)); From 20b388e9092f7e46a26f46337f4f592d211c86cc Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 28 Aug 2026 10:55:02 -0700 Subject: [PATCH 20/31] Report external stop CLI resolution Notify the outdated CLI checker when an external AppHost stop directly resolves its executable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/services/AppHostStopper.ts | 5 ++++- extension/src/test/appHostStopper.test.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/extension/src/services/AppHostStopper.ts b/extension/src/services/AppHostStopper.ts index 6101cf0273e..8961365651a 100644 --- a/extension/src/services/AppHostStopper.ts +++ b/extension/src/services/AppHostStopper.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { spawnCliProcess, terminateCliProcess } from '../utils/process/cliProcess'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { getCliPathTargetForUri } from '../utils/cliPathVariables'; +import { reportCliResolvedForOperation } from '../utils/cliOperationResolution'; const maxRetainedStderrLength = 16 * 1024; @@ -11,10 +12,12 @@ export async function stopExternalAppHost( cancellationToken: vscode.CancellationToken, ): Promise { - const cliPath = await terminalProvider.getAspireCliExecutablePath(getCliPathTargetForUri(vscode.Uri.file(appHostPath))); + const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath)); + const cliPath = await terminalProvider.getAspireCliExecutablePath(target); if (cancellationToken.isCancellationRequested) { throw new vscode.CancellationError(); } + reportCliResolvedForOperation(target, cliPath); await new Promise((resolve, reject) => { let settled = false; diff --git a/extension/src/test/appHostStopper.test.ts b/extension/src/test/appHostStopper.test.ts index 4d63237c27f..3ad66201e23 100644 --- a/extension/src/test/appHostStopper.test.ts +++ b/extension/src/test/appHostStopper.test.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { stopExternalAppHost } from '../services/AppHostStopper'; import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; suite('AppHostStopper', () => { test('waits for aspire stop to exit successfully', async () => { @@ -53,6 +54,8 @@ suite('AppHostStopper', () => { createEnvironment: () => ({}), sendAspireCommandToAspireTerminal: async () => { }, } as unknown as AspireTerminalProvider; + const resolutions: Array<{ target: unknown; cliPath: string }> = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); try { const stopping = stopExternalAppHost( @@ -62,12 +65,17 @@ suite('AppHostStopper', () => { await new Promise(resolve => setImmediate(resolve)); assert.ok(getAspireCliExecutablePathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); + assert.deepStrictEqual(resolutions, [{ + target: workspaceFolderCliPathTarget(folder), + cliPath: '/repo/bin/aspire', + }]); childState.exitCode = 0; child.emit('close', 0); await stopping; } finally { + subscription.dispose(); getWorkspaceFolderStub.restore(); (nodeChildProcess.spawn as sinon.SinonStub).restore(); } From bb69a8a46ef83e02b11c1258566052713fab4c74 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 28 Aug 2026 12:03:50 -0700 Subject: [PATCH 21/31] Correct stable CLI update example Use the real latest stable 13.5.2 version in update-warning tests and UI evidence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/test/configInfoProvider.test.ts | 4 ++-- extension/src/test/outdatedCliNotifier.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts index bf0785598c0..37ad3959c1b 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -294,8 +294,8 @@ suite('configInfoProvider tests', () => { test('parseCliUpdateRecommendationOutput accepts stable and prerelease recommendations', () => { assert.deepStrictEqual(parseCliUpdateRecommendationOutput( - createDoctorVersionOutput('13.4.0', '15.3.2')), - { status: 'available', currentVersion: '13.4.0', version: '15.3.2' }); + createDoctorVersionOutput('13.4.0', '13.5.2')), + { status: 'available', currentVersion: '13.4.0', version: '13.5.2' }); assert.deepStrictEqual(parseCliUpdateRecommendationOutput( createDoctorVersionOutput('13.5.0', '13.6.0')), { status: 'available', currentVersion: '13.5.0', version: '13.6.0' }); diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index 621b2ae7799..fecb5d5c56c 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -111,7 +111,7 @@ suite('outdatedCliNotifier', () => { versionProvider.recommendation = { status: 'available', currentVersion: '13.4.0', - version: '15.3.2', + version: '13.5.2', }; versionProvider.currentVersion = versionProvider.identity; surface.selection = strings.updateAspireCliAction; @@ -122,7 +122,7 @@ suite('outdatedCliNotifier', () => { assert.strictEqual(surface.warnings.length, 1); assert.strictEqual( surface.warnings[0].message, - 'Aspire CLI 13.4.0 at /workspace/a/.aspire/bin/aspire has a newer version available for its current channel: 15.3.2.'); + 'Aspire CLI 13.4.0 at /workspace/a/.aspire/bin/aspire has a newer version available for its current channel: 13.5.2.'); assert.deepStrictEqual(surface.warnings[0].actions, [ strings.updateAspireCliAction, strings.dontShowAgainLabel, From f70d5bfdd7ac014cbbbf3a0c03118fea88dedc23 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Fri, 28 Aug 2026 12:23:17 -0700 Subject: [PATCH 22/31] Bound concurrent CLI version probes Coalesce probes by executable, serialize distinct paths, and keep update-action revalidation fresh. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- .../src/test/outdatedCliNotifier.test.ts | 61 ++++++++++-- extension/src/utils/outdatedCliNotifier.ts | 94 +++++++++++++------ 2 files changed, 115 insertions(+), 40 deletions(-) diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index fecb5d5c56c..d620e4c151e 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -218,12 +218,20 @@ suite('outdatedCliNotifier', () => { test('coalesces same-path checks and serializes distinct doctors', async () => { const versionProvider = new FakeVersionProvider(); + let activeVersions = 0; + let maximumActiveVersions = 0; + const releaseVersions: Array<() => void> = []; versionProvider.getCliVersion = async options => { versionProvider.versionCalls.push(options); - return { - cliPath: options?.cliPath ?? '/cli/aspire', - version: '13.5.0', - }; + activeVersions++; + maximumActiveVersions = Math.max(maximumActiveVersions, activeVersions); + return await new Promise(resolve => releaseVersions.push(() => { + activeVersions--; + resolve({ + cliPath: options?.cliPath ?? '/cli/aspire', + version: '13.5.0', + }); + })); }; let activeDoctors = 0; let maximumActiveDoctors = 0; @@ -247,6 +255,10 @@ suite('outdatedCliNotifier', () => { const shared = Array.from({ length: 10 }, () => notifier.notifyIfOutdated(windowCliPathTarget, '/shared/aspire')); const distinct = notifier.notifyIfOutdated(windowCliPathTarget, '/other/aspire'); + await waitFor(() => releaseVersions.length === 1, 'Expected first serialized version probe.'); + releaseVersions.shift()?.(); + await waitFor(() => releaseVersions.length === 1, 'Expected second serialized version probe.'); + releaseVersions.shift()?.(); await waitFor(() => releaseDoctors.length === 1, 'Expected first serialized doctor.'); releaseDoctors.shift()?.(); await waitFor(() => releaseDoctors.length === 1, 'Expected second serialized doctor.'); @@ -254,6 +266,7 @@ suite('outdatedCliNotifier', () => { await Promise.all([...shared, distinct]); assert.strictEqual(versionProvider.versionCalls.length, 2); + assert.strictEqual(maximumActiveVersions, 1); assert.strictEqual(versionProvider.recommendationCalls.length, 2); assert.strictEqual(maximumActiveDoctors, 1); notifier.dispose(); @@ -286,9 +299,12 @@ suite('outdatedCliNotifier', () => { const surface = new FakeSurface(); const notifier = new OutdatedCliNotifier(versionProvider, surface); - await notifier.notifyIfOutdated(targetA, '/shared/aspire'); - await notifier.notifyIfOutdated(targetB, '/shared/aspire'); + await Promise.all([ + notifier.notifyIfOutdated(targetA, '/shared/aspire'), + notifier.notifyIfOutdated(targetB, '/shared/aspire'), + ]); + assert.strictEqual(versionProvider.versionCalls.length, 1); assert.deepStrictEqual( versionProvider.recommendationCalls.map(call => call?.target), [targetA, targetB]); @@ -366,12 +382,37 @@ suite('outdatedCliNotifier', () => { const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); await waitFor(() => surface.warnings.length === 1, 'Expected warning to open.'); - versionProvider.currentVersion = { - cliPath: '/cli/aspire', - version: '13.5.1', + versionProvider.versionCalls.length = 0; + let releaseOlderProbe!: () => void; + versionProvider.getCliVersion = async options => { + versionProvider.versionCalls.push(options); + if (versionProvider.versionCalls.length === 1) { + return await new Promise(resolve => { + releaseOlderProbe = () => resolve({ + cliPath: '/cli/aspire', + version: '13.5.0', + }); + }); + } + return { + cliPath: '/cli/aspire', + version: '13.5.1', + }; }; + const backgroundCheck = notifier.notifyIfOutdated( + workspaceFolderCliPathTarget({ + uri: vscode.Uri.file('/other'), + name: 'other', + index: 0, + }), + '/cli/aspire'); + await waitFor(() => versionProvider.versionCalls.length === 1, 'Expected older probe to start.'); resolveSelection(strings.updateAspireCliAction); - await notification; + await new Promise(resolve => setImmediate(resolve)); + releaseOlderProbe(); + await Promise.all([notification, backgroundCheck]); + + assert.strictEqual(versionProvider.versionCalls.length, 2); assert.deepStrictEqual(surface.commands, []); const second = createNotifier(); diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index bf73e3ff09e..dc4b11fce1a 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -56,8 +56,10 @@ export class OutdatedCliNotifier implements vscode.Disposable { private readonly _notifiedCliVersions = new Set(); private readonly _persistentlySuppressedCliVersions: Set; private readonly _inFlightByCheckKey = new Map>(); + private readonly _inFlightVersionByCliPath = new Map>(); private readonly _cancellationSource = new vscode.CancellationTokenSource(); - private _doctorTail: Promise = Promise.resolve(); + private readonly _versionQueue = new AsyncSerialQueue(); + private readonly _doctorQueue = new AsyncSerialQueue(); private _disposed = false; constructor( @@ -129,11 +131,10 @@ export class OutdatedCliNotifier implements vscode.Disposable { } // This user-initiated guard intentionally bypasses the five-minute cache. - const currentVersionProbe = await this._versionProvider.getCliVersion({ - target: notification.target, - cliPath: notification.cli.cliPath, - cancellationToken: this._cancellationSource.token, - }); + const currentVersionProbe = await this._getCliVersion( + notification.target, + notification.cli.cliPath, + false); if (this._disposed) { return; } @@ -144,6 +145,39 @@ export class OutdatedCliNotifier implements vscode.Disposable { await this._surface.executeCommand(updateAspireCliCommand, notification.target, notification.cli.cliPath); } + private _getCliVersion( + target: CliPathResolutionTarget, + cliPath: string, + coalesce = true, + ): Promise { + const cliPathKey = getComparisonKey(path.normalize(cliPath)); + const startProbe = () => this._versionQueue.run( + () => this._versionProvider.getCliVersion({ + target, + cliPath, + cancellationToken: this._cancellationSource.token, + }), + () => this._disposed); + if (!coalesce) { + return startProbe(); + } + + const existing = this._inFlightVersionByCliPath.get(cliPathKey); + if (existing) { + return existing; + } + + let probe!: Promise; + probe = startProbe() + .finally(() => { + if (this._inFlightVersionByCliPath.get(cliPathKey) === probe) { + this._inFlightVersionByCliPath.delete(cliPathKey); + } + }); + this._inFlightVersionByCliPath.set(cliPathKey, probe); + return probe; + } + private async _checkForUpdate( target: CliPathResolutionTarget, checkKey: string, @@ -151,11 +185,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { workingDirectory: string, checkStartedAt: number, ): Promise { - const versionProbe = await this._versionProvider.getCliVersion({ - target, - cliPath, - cancellationToken: this._cancellationSource.token, - }); + const versionProbe = await this._getCliVersion(target, cliPath); if (this._disposed) { return undefined; } @@ -203,13 +233,14 @@ export class OutdatedCliNotifier implements vscode.Disposable { return undefined; } - const recommendation = await this._runDoctor(() => - this._versionProvider.getCliUpdateRecommendation({ + const recommendation = await this._doctorQueue.run( + () => this._versionProvider.getCliUpdateRecommendation({ target, cliPath, workingDirectory, cancellationToken: this._cancellationSource.token, - })); + }), + () => this._disposed); if (recommendation === undefined || this._disposed) { return undefined; } @@ -239,22 +270,6 @@ export class OutdatedCliNotifier implements vscode.Disposable { : undefined; } - private async _runDoctor( - action: () => Promise, - ): Promise { - const previous = this._doctorTail; - let release!: () => void; - this._doctorTail = new Promise(resolve => release = resolve); - await previous; - - try { - return this._disposed ? undefined : await action(); - } - finally { - release(); - } - } - private _recordUnavailable(state: CliCheckState): void { state.failureCount = state.updateStatus === 'unavailable' ? state.failureCount + 1 : 1; state.updateStatus = 'unavailable'; @@ -292,6 +307,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { this._cancellationSource.cancel(); this._cancellationSource.dispose(); this._inFlightByCheckKey.clear(); + this._inFlightVersionByCliPath.clear(); this._stateByCheckKey.clear(); this._notifiedCliVersions.clear(); } @@ -319,3 +335,21 @@ function readPersistedSuppressions(globalState: vscode.Memento | undefined): str function areCliIdentitiesEqual(left: CliVersionInfo | undefined, right: CliVersionInfo): boolean { return left?.version === right.version; } + +class AsyncSerialQueue { + private _tail: Promise = Promise.resolve(); + + async run(action: () => Promise, isCancelled: () => boolean): Promise { + const previous = this._tail; + let release!: () => void; + this._tail = new Promise(resolve => release = resolve); + await previous; + + try { + return isCancelled() ? undefined : await action(); + } + finally { + release(); + } + } +} From 91fa61f3e172e2f1af85bbca16d88d274a103527 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 1 Sep 2026 09:11:54 -0700 Subject: [PATCH 23/31] Fix cross-window CLI warning suppression Store suppressions as append-only atomic markers so concurrently open extension hosts cannot overwrite each other's choices. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/extension.ts | 3 +- .../src/test/outdatedCliNotifier.test.ts | 46 ++++++++-------- .../test/outdatedCliSuppressionStore.test.ts | 27 ++++++++++ extension/src/utils/outdatedCliNotifier.ts | 47 +++++++++++----- .../src/utils/outdatedCliSuppressionStore.ts | 54 +++++++++++++++++++ 5 files changed, 142 insertions(+), 35 deletions(-) create mode 100644 extension/src/test/outdatedCliSuppressionStore.test.ts create mode 100644 extension/src/utils/outdatedCliSuppressionStore.ts diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 9c6f899a951..2f32bcac9bf 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -40,6 +40,7 @@ import { registerCodeLensCommands } from './activation/registerCodeLensCommands' import { initializeHotReloadAdvisory } from './debugger/hotReload'; import { OutdatedCliNotifier } from './utils/outdatedCliNotifier'; import { onDidResolveCliForOperation } from './utils/cliOperationResolution'; +import { FileSystemOutdatedCliSuppressionStore } from './utils/outdatedCliSuppressionStore'; let aspireExtensionContext = new AspireExtensionContext(); @@ -107,7 +108,7 @@ export async function activate(context: vscode.ExtensionContext) { configInfoProvider, undefined, Date.now, - context.globalState); + new FileSystemOutdatedCliSuppressionStore(context.globalStorageUri.fsPath)); context.subscriptions.push(outdatedCliNotifier); context.subscriptions.push(onDidResolveCliForOperation(({ target, cliPath }) => { void outdatedCliNotifier.notifyIfOutdated(target, cliPath).catch(error => { diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index d620e4c151e..d41e734a438 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -10,6 +10,7 @@ import { } from '../utils/configInfoProvider'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier'; +import { OutdatedCliSuppressionStore } from '../utils/outdatedCliSuppressionStore'; suite('outdatedCliNotifier', () => { class FakeVersionProvider { @@ -24,6 +25,7 @@ suite('outdatedCliNotifier', () => { currentVersion: '13.5.0', version: '13.6.0', }; + recommendationPromise: Promise | undefined; readonly versionCalls: Array = []; readonly recommendationCalls: Array = []; @@ -36,7 +38,7 @@ suite('outdatedCliNotifier', () => { async getCliUpdateRecommendation(options?: CliUpdateRecommendationOptions): Promise { this.recommendationCalls.push(options); - return this.recommendation; + return await (this.recommendationPromise ?? this.recommendation); } } @@ -57,7 +59,7 @@ suite('outdatedCliNotifier', () => { } } - function createNotifier(now: () => number = Date.now, globalState?: vscode.Memento): { + function createNotifier(now: () => number = Date.now, suppressionStore?: OutdatedCliSuppressionStore): { notifier: OutdatedCliNotifier; versionProvider: FakeVersionProvider; surface: FakeSurface; @@ -65,25 +67,16 @@ suite('outdatedCliNotifier', () => { const versionProvider = new FakeVersionProvider(); const surface = new FakeSurface(); return { - notifier: new OutdatedCliNotifier(versionProvider, surface, now, globalState), + notifier: new OutdatedCliNotifier(versionProvider, surface, now, suppressionStore), versionProvider, surface, }; } - function createMemento(): vscode.Memento { - const values = new Map(); + function createSuppressionStore(values = new Set()): OutdatedCliSuppressionStore { return { - keys: () => [...values.keys()], - get: (key: string, defaultValue?: T): T | undefined => - values.has(key) ? values.get(key) as T : defaultValue, - update: async (key: string, value: unknown): Promise => { - if (value === undefined) { - values.delete(key); - } else { - values.set(key, value); - } - }, + readAll: async () => [...values], + add: async notificationKey => void values.add(notificationKey), }; } @@ -135,22 +128,33 @@ suite('outdatedCliNotifier', () => { }); test("Don't Show Again persists for the exact CLI path and version", async () => { - const globalState = createMemento(); - const first = createNotifier(Date.now, globalState); + const values = new Set(); + const first = createNotifier(Date.now, createSuppressionStore(values)); + const second = createNotifier(Date.now, createSuppressionStore(values)); first.surface.selection = strings.dontShowAgainLabel; + let completeSecondRecommendation!: (recommendation: CliUpdateRecommendation) => void; + second.versionProvider.recommendationPromise = new Promise(resolve => completeSecondRecommendation = resolve); + + const secondNotification = second.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await waitFor( + () => second.versionProvider.recommendationCalls.length === 1, + 'Expected the second window update probe to start.'); await first.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + completeSecondRecommendation({ + status: 'available', + currentVersion: '13.5.0', + version: '13.6.0', + }); + await secondNotification; assert.strictEqual(first.surface.warnings.length, 1); assert.deepStrictEqual(first.surface.commands, []); first.notifier.dispose(); - const second = createNotifier(Date.now, globalState); - await second.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); - assert.deepStrictEqual(second.surface.warnings, []); assert.strictEqual(second.versionProvider.versionCalls.length, 1); - assert.deepStrictEqual(second.versionProvider.recommendationCalls, []); + assert.strictEqual(second.versionProvider.recommendationCalls.length, 1); second.notifier.dispose(); }); diff --git a/extension/src/test/outdatedCliSuppressionStore.test.ts b/extension/src/test/outdatedCliSuppressionStore.test.ts new file mode 100644 index 00000000000..b602b4923c3 --- /dev/null +++ b/extension/src/test/outdatedCliSuppressionStore.test.ts @@ -0,0 +1,27 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { FileSystemOutdatedCliSuppressionStore } from '../utils/outdatedCliSuppressionStore'; + +suite('outdatedCliSuppressionStore', () => { + test('preserves concurrent writes from separate stores', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-suppressions-')); + const first = new FileSystemOutdatedCliSuppressionStore(directory); + const second = new FileSystemOutdatedCliSuppressionStore(directory); + + try { + await Promise.all([ + first.add('/cli/a\u000013.5.0'), + second.add('/cli/b\u000013.5.0'), + ]); + + assert.deepStrictEqual( + (await first.readAll()).sort(), + ['/cli/a\u000013.5.0', '/cli/b\u000013.5.0']); + } + finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index dc4b11fce1a..3b8e84048a8 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -10,6 +10,7 @@ import { } from './configInfoProvider'; import { CliPathResolutionTarget, getCliPathTargetKey } from './cliPathVariables'; import { extensionLogOutputChannel } from './logging'; +import { OutdatedCliSuppressionStore } from './outdatedCliSuppressionStore'; import { getComparisonKey } from './paths/comparison'; const updateAspireCliCommand = 'aspire-vscode.updateSelf'; @@ -19,7 +20,6 @@ const completedUpdateRefreshIntervalMs = 6 * 60 * 60 * 1_000; const unavailableRetryBaseMs = 60 * 1_000; const unavailableRetryMaximumMs = 30 * 60 * 1_000; const maximumUnavailableAttemptsPerIdentity = 3; -const persistedSuppressionKey = 'outdatedCliNotification.suppressedCliVersions'; type CliVersionProvider = Pick; @@ -66,9 +66,9 @@ export class OutdatedCliNotifier implements vscode.Disposable { private readonly _versionProvider: CliVersionProvider, private readonly _surface: OutdatedCliNotificationSurface = defaultSurface, private readonly _now: () => number = Date.now, - private readonly _globalState?: vscode.Memento, + private readonly _suppressionStore?: OutdatedCliSuppressionStore, ) { - this._persistentlySuppressedCliVersions = new Set(readPersistedSuppressions(_globalState)); + this._persistentlySuppressedCliVersions = new Set(); } async notifyIfOutdated(target: CliPathResolutionTarget, cliPath: string): Promise { @@ -81,6 +81,9 @@ export class OutdatedCliNotifier implements vscode.Disposable { if ((this._stateByCheckKey.get(checkKey)?.versionValidUntil ?? 0) > this._now()) { return; } + if (!await this._refreshPersistedSuppressions() || this._disposed) { + return; + } const existingProbe = this._inFlightByCheckKey.get(checkKey); if (existingProbe) { await existingProbe; @@ -105,6 +108,16 @@ export class OutdatedCliNotifier implements vscode.Disposable { return; } + if (!await this._refreshPersistedSuppressions() || this._disposed) { + const state = this._stateByCheckKey.get(checkKey); + if (state?.identity && areCliIdentitiesEqual(state.identity, notification.cli)) { + state.versionValidUntil = 0; + state.updateStatus = undefined; + state.updateValidUntil = 0; + state.failureCount = 0; + } + return; + } const notificationKey = getNotificationKey(notification.cli.cliPath, notification.cli.version); if (this._notifiedCliVersions.has(notificationKey) || this._persistentlySuppressedCliVersions.has(notificationKey)) { @@ -145,6 +158,23 @@ export class OutdatedCliNotifier implements vscode.Disposable { await this._surface.executeCommand(updateAspireCliCommand, notification.target, notification.cli.cliPath); } + private async _refreshPersistedSuppressions(): Promise { + if (!this._suppressionStore) { + return true; + } + + try { + for (const notificationKey of await this._suppressionStore.readAll()) { + this._persistentlySuppressedCliVersions.add(notificationKey); + } + return true; + } + catch (error) { + extensionLogOutputChannel.warn(`Unable to read Aspire CLI warning suppressions: ${String(error)}`); + return false; + } + } + private _getCliVersion( target: CliPathResolutionTarget, cliPath: string, @@ -289,9 +319,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { this._persistentlySuppressedCliVersions.add(notificationKey); try { - await this._globalState?.update( - persistedSuppressionKey, - [...this._persistentlySuppressedCliVersions]); + await this._suppressionStore?.add(notificationKey); } catch (error) { extensionLogOutputChannel.warn(`Unable to persist Aspire CLI warning suppression: ${String(error)}`); @@ -325,13 +353,6 @@ function getCliCheckKey( return `${getCliPathTargetKey(target)}\u0000${getComparisonKey(path.normalize(cliPath))}\u0000${getComparisonKey(path.normalize(workingDirectory))}`; } -function readPersistedSuppressions(globalState: vscode.Memento | undefined): string[] { - const values = globalState?.get(persistedSuppressionKey); - return Array.isArray(values) - ? values.filter((value): value is string => typeof value === 'string') - : []; -} - function areCliIdentitiesEqual(left: CliVersionInfo | undefined, right: CliVersionInfo): boolean { return left?.version === right.version; } diff --git a/extension/src/utils/outdatedCliSuppressionStore.ts b/extension/src/utils/outdatedCliSuppressionStore.ts new file mode 100644 index 00000000000..03ae54b298b --- /dev/null +++ b/extension/src/utils/outdatedCliSuppressionStore.ts @@ -0,0 +1,54 @@ +import { mkdir, readFile, readdir, rename, writeFile } from 'fs/promises'; +import * as path from 'path'; + +const suppressionDirectoryName = 'outdated-cli-suppressions'; +const suppressionFilePrefix = 'suppression-'; +let suppressionSequence = 0; + +export interface OutdatedCliSuppressionStore { + readAll(): Promise; + add(notificationKey: string): Promise; +} + +/** + * Publishes each suppression as an immutable file so separate extension hosts can add entries + * without a cross-window read-modify-write race. + */ +export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppressionStore { + private readonly _directoryPath: string; + + constructor(globalStoragePath: string) { + this._directoryPath = path.join(globalStoragePath, suppressionDirectoryName); + } + + async readAll(): Promise { + await mkdir(this._directoryPath, { recursive: true }); + const entries = await readdir(this._directoryPath, { withFileTypes: true }); + const suppressions: string[] = []; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith(suppressionFilePrefix) || !entry.name.endsWith('.json')) { + continue; + } + + const notificationKey = JSON.parse(await readFile(path.join(this._directoryPath, entry.name), 'utf8')) as unknown; + if (typeof notificationKey !== 'string') { + throw new Error(`Invalid Aspire CLI warning suppression file: ${entry.name}`); + } + suppressions.push(notificationKey); + } + + return suppressions; + } + + async add(notificationKey: string): Promise { + await mkdir(this._directoryPath, { recursive: true }); + const generation = `${Date.now()}-${process.pid}-${suppressionSequence++}`; + const fileName = `${suppressionFilePrefix}${generation}.json`; + const temporaryPath = path.join(this._directoryPath, `.${fileName}.tmp`); + const finalPath = path.join(this._directoryPath, fileName); + + await writeFile(temporaryPath, JSON.stringify(notificationKey), { encoding: 'utf8', flag: 'wx' }); + await rename(temporaryPath, finalPath); + } +} From bf2d6ea5542833b213a2f25e20a2967eb546c4ae Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 1 Sep 2026 12:45:13 -0700 Subject: [PATCH 24/31] Make CLI warning suppression linearizable Serialize the final suppression check and warning dispatch with suppression writes across extension hosts, while retaining in-session deduplication and recovering abandoned leases. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- .../src/test/outdatedCliNotifier.test.ts | 73 +++++++- .../test/outdatedCliSuppressionStore.test.ts | 84 +++++++-- extension/src/utils/outdatedCliNotifier.ts | 78 ++++++-- .../src/utils/outdatedCliSuppressionStore.ts | 176 +++++++++++++++++- 4 files changed, 368 insertions(+), 43 deletions(-) diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index d41e734a438..0f6e45cd7fd 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -10,7 +10,10 @@ import { } from '../utils/configInfoProvider'; import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier'; -import { OutdatedCliSuppressionStore } from '../utils/outdatedCliSuppressionStore'; +import { + OutdatedCliNotificationClaim, + OutdatedCliSuppressionStore, +} from '../utils/outdatedCliSuppressionStore'; suite('outdatedCliNotifier', () => { class FakeVersionProvider { @@ -77,9 +80,16 @@ suite('outdatedCliNotifier', () => { return { readAll: async () => [...values], add: async notificationKey => void values.add(notificationKey), + tryClaimNotification: async notificationKey => values.has(notificationKey) + ? undefined + : createNotificationClaim(), }; } + function createNotificationClaim(): OutdatedCliNotificationClaim { + return { release: async () => undefined }; + } + async function waitFor(predicate: () => boolean, message: string): Promise { for (let attempt = 0; attempt < 100; attempt++) { if (predicate()) { @@ -158,6 +168,67 @@ suite('outdatedCliNotifier', () => { second.notifier.dispose(); }); + test('reserves the notification before awaiting a cross-window claim', async () => { + const target = workspaceFolderCliPathTarget({ + uri: vscode.Uri.file('/workspace/a'), + name: 'a', + index: 0, + }); + let completeClaim!: (claim: OutdatedCliNotificationClaim) => void; + let claimCalls = 0; + const suppressionStore: OutdatedCliSuppressionStore = { + readAll: async () => [], + add: async () => undefined, + tryClaimNotification: async () => { + claimCalls++; + return claimCalls === 1 + ? await new Promise(resolve => completeClaim = resolve) + : createNotificationClaim(); + }, + }; + const { notifier, surface } = createNotifier(Date.now, suppressionStore); + + const first = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await waitFor(() => claimCalls === 1, 'Expected the first notification claim.'); + const second = notifier.notifyIfOutdated(target, '/cli/aspire'); + await second; + completeClaim(createNotificationClaim()); + await first; + + assert.strictEqual(claimCalls, 1); + assert.strictEqual(surface.warnings.length, 1); + notifier.dispose(); + }); + + test('holds the cross-window claim only through warning dispatch', async () => { + let claimReleased = false; + let completeSelection!: (selection: string | undefined) => void; + const suppressionStore: OutdatedCliSuppressionStore = { + readAll: async () => [], + add: async () => undefined, + tryClaimNotification: async () => ({ + release: async () => { + claimReleased = true; + }, + }), + }; + const { notifier, surface } = createNotifier(Date.now, suppressionStore); + surface.selectionPromise = new Promise(resolve => completeSelection = resolve); + const showWarning = surface.showWarning.bind(surface); + surface.showWarning = (message, ...actions) => { + assert.strictEqual(claimReleased, false); + return showWarning(message, ...actions); + }; + + const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await waitFor(() => claimReleased, 'Expected the claim to be released after warning dispatch.'); + assert.strictEqual(surface.warnings.length, 1); + + completeSelection(undefined); + await notification; + notifier.dispose(); + }); + test('uses five-minute version and six-hour update refresh intervals', async () => { let now = 0; const { notifier, versionProvider, surface } = createNotifier(() => now); diff --git a/extension/src/test/outdatedCliSuppressionStore.test.ts b/extension/src/test/outdatedCliSuppressionStore.test.ts index b602b4923c3..e0edaa49e47 100644 --- a/extension/src/test/outdatedCliSuppressionStore.test.ts +++ b/extension/src/test/outdatedCliSuppressionStore.test.ts @@ -1,27 +1,83 @@ import * as assert from 'assert'; +import { spawn } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { FileSystemOutdatedCliSuppressionStore } from '../utils/outdatedCliSuppressionStore'; suite('outdatedCliSuppressionStore', () => { + let directory: string; + + setup(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-suppressions-')); + }); + + teardown(() => { + fs.rmSync(directory, { recursive: true, force: true }); + }); + test('preserves concurrent writes from separate stores', async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-suppressions-')); const first = new FileSystemOutdatedCliSuppressionStore(directory); const second = new FileSystemOutdatedCliSuppressionStore(directory); - try { - await Promise.all([ - first.add('/cli/a\u000013.5.0'), - second.add('/cli/b\u000013.5.0'), - ]); - - assert.deepStrictEqual( - (await first.readAll()).sort(), - ['/cli/a\u000013.5.0', '/cli/b\u000013.5.0']); - } - finally { - fs.rmSync(directory, { recursive: true, force: true }); - } + await Promise.all([ + first.add('/cli/a\u000013.5.0'), + second.add('/cli/b\u000013.5.0'), + ]); + + assert.deepStrictEqual( + (await first.readAll()).sort(), + ['/cli/a\u000013.5.0', '/cli/b\u000013.5.0']); + }); + + test('serializes a suppression written after the final notification check', async () => { + const first = new FileSystemOutdatedCliSuppressionStore(directory); + const second = new FileSystemOutdatedCliSuppressionStore(directory); + const notificationKey = '/cli/aspire\u000013.5.0'; + + const claim = await second.tryClaimNotification(notificationKey); + assert.ok(claim); + + let suppressionCompleted = false; + const suppression = first.add(notificationKey).then(() => suppressionCompleted = true); + await new Promise(resolve => setTimeout(resolve, 50)); + assert.strictEqual(suppressionCompleted, false); + + await claim.release(); + await suppression; + + assert.strictEqual(suppressionCompleted, true); + assert.strictEqual(await second.tryClaimNotification(notificationKey), undefined); + }); + + test('recovers a lock abandoned by another extension host', async () => { + const exitedProcessId = await startAndWaitForProcess(); + const storageDirectory = path.join(directory, 'outdated-cli-suppressions'); + const ownerFileName = `.operation-lock-owner-${Date.now()}-${exitedProcessId}-0`; + const ownerPath = path.join(storageDirectory, ownerFileName); + fs.mkdirSync(storageDirectory, { recursive: true }); + fs.writeFileSync(ownerPath, ownerFileName); + fs.linkSync(ownerPath, path.join(storageDirectory, '.operation-lock')); + + const first = new FileSystemOutdatedCliSuppressionStore(directory); + const second = new FileSystemOutdatedCliSuppressionStore(directory); + await Promise.all([ + first.add('/cli/a\u000013.5.0'), + second.add('/cli/b\u000013.5.0'), + ]); + + assert.deepStrictEqual( + (await first.readAll()).sort(), + ['/cli/a\u000013.5.0', '/cli/b\u000013.5.0']); }); }); + +async function startAndWaitForProcess(): Promise { + const child = spawn(process.execPath, ['-e', '']); + assert.ok(child.pid); + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', () => resolve()); + }); + return child.pid; +} diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 3b8e84048a8..fbf22b90891 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -10,7 +10,7 @@ import { } from './configInfoProvider'; import { CliPathResolutionTarget, getCliPathTargetKey } from './cliPathVariables'; import { extensionLogOutputChannel } from './logging'; -import { OutdatedCliSuppressionStore } from './outdatedCliSuppressionStore'; +import { OutdatedCliNotificationClaim, OutdatedCliSuppressionStore } from './outdatedCliSuppressionStore'; import { getComparisonKey } from './paths/comparison'; const updateAspireCliCommand = 'aspire-vscode.updateSelf'; @@ -108,30 +108,48 @@ export class OutdatedCliNotifier implements vscode.Disposable { return; } - if (!await this._refreshPersistedSuppressions() || this._disposed) { - const state = this._stateByCheckKey.get(checkKey); - if (state?.identity && areCliIdentitiesEqual(state.identity, notification.cli)) { - state.versionValidUntil = 0; - state.updateStatus = undefined; - state.updateValidUntil = 0; - state.failureCount = 0; - } - return; - } const notificationKey = getNotificationKey(notification.cli.cliPath, notification.cli.version); if (this._notifiedCliVersions.has(notificationKey) || this._persistentlySuppressedCliVersions.has(notificationKey)) { return; } - this._notifiedCliVersions.add(notificationKey); - const selection = await this._surface.showWarning( - strings.outdatedAspireCliWarning( - notification.cli.version, - notification.cli.cliPath, - notification.recommendedVersion), - strings.updateAspireCliAction, - strings.dontShowAgainLabel); + + let claim: OutdatedCliNotificationClaim | undefined; + if (this._suppressionStore) { + try { + claim = await this._suppressionStore.tryClaimNotification(notificationKey); + } + catch (error) { + this._notifiedCliVersions.delete(notificationKey); + extensionLogOutputChannel.warn(`Unable to claim Aspire CLI update notification: ${String(error)}`); + this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli); + return; + } + if (!claim) { + this._persistentlySuppressedCliVersions.add(notificationKey); + return; + } + } + if (this._disposed) { + await this._releaseNotificationClaim(claim); + return; + } + + let selectionPromise: Thenable; + try { + selectionPromise = this._surface.showWarning( + strings.outdatedAspireCliWarning( + notification.cli.version, + notification.cli.cliPath, + notification.recommendedVersion), + strings.updateAspireCliAction, + strings.dontShowAgainLabel); + } + finally { + await this._releaseNotificationClaim(claim); + } + const selection = await selectionPromise; if (this._disposed) { return; } @@ -175,6 +193,28 @@ export class OutdatedCliNotifier implements vscode.Disposable { } } + private _invalidateRecommendationAfterSuppressionFailure( + checkKey: string, + identity: CliVersionInfo, + ): void { + const state = this._stateByCheckKey.get(checkKey); + if (state?.identity && areCliIdentitiesEqual(state.identity, identity)) { + state.versionValidUntil = 0; + state.updateStatus = undefined; + state.updateValidUntil = 0; + state.failureCount = 0; + } + } + + private async _releaseNotificationClaim(claim: OutdatedCliNotificationClaim | undefined): Promise { + try { + await claim?.release(); + } + catch (error) { + extensionLogOutputChannel.warn(`Unable to release Aspire CLI update notification claim: ${String(error)}`); + } + } + private _getCliVersion( target: CliPathResolutionTarget, cliPath: string, diff --git a/extension/src/utils/outdatedCliSuppressionStore.ts b/extension/src/utils/outdatedCliSuppressionStore.ts index 03ae54b298b..a40fc6806a2 100644 --- a/extension/src/utils/outdatedCliSuppressionStore.ts +++ b/extension/src/utils/outdatedCliSuppressionStore.ts @@ -1,28 +1,79 @@ -import { mkdir, readFile, readdir, rename, writeFile } from 'fs/promises'; +import { link, mkdir, readFile, readdir, rename, unlink, writeFile } from 'fs/promises'; import * as path from 'path'; +import { setTimeout as delay } from 'timers/promises'; const suppressionDirectoryName = 'outdated-cli-suppressions'; const suppressionFilePrefix = 'suppression-'; +const operationLockFileName = '.operation-lock'; +const operationLockOwnerPrefix = '.operation-lock-owner-'; +const operationLockRetryIntervalMs = 10; +const operationLockAcquireTimeoutMs = 5_000; let suppressionSequence = 0; +let operationLockSequence = 0; + +/** Holds suppression writes until the warning has been dispatched, but not while awaiting user input. */ +export interface OutdatedCliNotificationClaim { + release(): Promise; +} export interface OutdatedCliSuppressionStore { readAll(): Promise; add(notificationKey: string): Promise; + tryClaimNotification(notificationKey: string): Promise; } /** * Publishes each suppression as an immutable file so separate extension hosts can add entries - * without a cross-window read-modify-write race. + * without a cross-window read-modify-write race. A short filesystem lease serializes suppression + * writes with the final notification claim, closing the gap between the last read and showing UI. */ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppressionStore { private readonly _directoryPath: string; + private readonly _operationLockPath: string; constructor(globalStoragePath: string) { this._directoryPath = path.join(globalStoragePath, suppressionDirectoryName); + this._operationLockPath = path.join(this._directoryPath, operationLockFileName); } async readAll(): Promise { await mkdir(this._directoryPath, { recursive: true }); + return await this._readAll(); + } + + async add(notificationKey: string): Promise { + const release = await this._acquireOperationLock(); + try { + const generation = `${Date.now()}-${process.pid}-${suppressionSequence++}`; + const fileName = `${suppressionFilePrefix}${generation}.json`; + const temporaryPath = path.join(this._directoryPath, `.${fileName}.tmp`); + const finalPath = path.join(this._directoryPath, fileName); + + await writeFile(temporaryPath, JSON.stringify(notificationKey), { encoding: 'utf8', flag: 'wx' }); + await rename(temporaryPath, finalPath); + } + finally { + await release(); + } + } + + async tryClaimNotification(notificationKey: string): Promise { + const release = await this._acquireOperationLock(); + try { + if ((await this._readAll()).includes(notificationKey)) { + await release(); + return undefined; + } + + return { release }; + } + catch (error) { + await release(); + throw error; + } + } + + private async _readAll(): Promise { const entries = await readdir(this._directoryPath, { withFileTypes: true }); const suppressions: string[] = []; @@ -31,6 +82,8 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres continue; } + // Each marker contains a JSON string: + // "C:\\tools\\aspire.exe\u000013.5.0" const notificationKey = JSON.parse(await readFile(path.join(this._directoryPath, entry.name), 'utf8')) as unknown; if (typeof notificationKey !== 'string') { throw new Error(`Invalid Aspire CLI warning suppression file: ${entry.name}`); @@ -41,14 +94,119 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres return suppressions; } - async add(notificationKey: string): Promise { + private async _acquireOperationLock(): Promise<() => Promise> { await mkdir(this._directoryPath, { recursive: true }); - const generation = `${Date.now()}-${process.pid}-${suppressionSequence++}`; - const fileName = `${suppressionFilePrefix}${generation}.json`; - const temporaryPath = path.join(this._directoryPath, `.${fileName}.tmp`); - const finalPath = path.join(this._directoryPath, fileName); + const startedAt = Date.now(); + + while (true) { + const generation = `${Date.now()}-${process.pid}-${operationLockSequence++}`; + const ownerFileName = `${operationLockOwnerPrefix}${generation}`; + const ownerPath = path.join(this._directoryPath, ownerFileName); + await writeFile(ownerPath, ownerFileName, { encoding: 'utf8', flag: 'wx' }); + try { + try { + // The fixed path and unique owner path become hard links to the same file. + // Creating the fixed link is the single atomic cross-process claim operation. + await link(ownerPath, this._operationLockPath); + } + catch (error) { + if (!hasErrorCode(error, 'EEXIST')) { + throw error; + } + await unlink(ownerPath); + await this._recoverAbandonedOperationLock(); - await writeFile(temporaryPath, JSON.stringify(notificationKey), { encoding: 'utf8', flag: 'wx' }); - await rename(temporaryPath, finalPath); + if (Date.now() - startedAt >= operationLockAcquireTimeoutMs) { + throw new Error('Timed out acquiring the Aspire CLI warning suppression lock.'); + } + await delay(operationLockRetryIntervalMs); + continue; + } + + let released = false; + return async () => { + if (released) { + return; + } + await this._releaseOperationLock(ownerPath); + released = true; + }; + } + catch (error) { + await unlink(ownerPath).catch(error => { + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } + }); + throw error; + } + } } + + private async _recoverAbandonedOperationLock(): Promise { + try { + // The lock file contains its unique owner filename: + // .operation-lock-owner-1788280000000-12345-0 + const ownerFileName = await readFile(this._operationLockPath, 'utf8'); + const match = /^\.operation-lock-owner-\d+-(\d+)-\d+$/.exec(ownerFileName); + if (!match || isProcessRunning(Number(match[1]))) { + return; + } + + try { + // Removing the unique owner link grants one cleaner authority to remove the fixed + // link. Other cleaners see ENOENT and leave any replacement lock untouched. + await unlink(path.join(this._directoryPath, ownerFileName)); + } + catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return; + } + throw error; + } + + await unlink(this._operationLockPath); + } + catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return; + } + throw error; + } + } + + private async _releaseOperationLock(ownerPath: string): Promise { + try { + await unlink(this._operationLockPath); + } + catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } + } + + try { + await unlink(ownerPath); + } + catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } + } + } +} + +function isProcessRunning(processId: number): boolean { + try { + process.kill(processId, 0); + return true; + } + catch (error) { + return !hasErrorCode(error, 'ESRCH'); + } +} + +function hasErrorCode(error: unknown, ...codes: string[]): boolean { + const code = error instanceof Error && 'code' in error ? error.code : undefined; + return typeof code === 'string' && codes.includes(code); } From a0dbdd08581d40eaf7ffa406b339613adb2f343b Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 1 Sep 2026 13:07:50 -0700 Subject: [PATCH 25/31] Make suppression lease recovery transferable Transfer abandoned-lock cleanup through an atomically renamed recovery link so another extension host can safely resume if a cleaner exits mid-recovery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- .../test/outdatedCliSuppressionStore.test.ts | 33 +++++-- .../src/utils/outdatedCliSuppressionStore.ts | 91 ++++++++++++++++--- 2 files changed, 106 insertions(+), 18 deletions(-) diff --git a/extension/src/test/outdatedCliSuppressionStore.test.ts b/extension/src/test/outdatedCliSuppressionStore.test.ts index e0edaa49e47..d976cc48773 100644 --- a/extension/src/test/outdatedCliSuppressionStore.test.ts +++ b/extension/src/test/outdatedCliSuppressionStore.test.ts @@ -52,12 +52,7 @@ suite('outdatedCliSuppressionStore', () => { test('recovers a lock abandoned by another extension host', async () => { const exitedProcessId = await startAndWaitForProcess(); - const storageDirectory = path.join(directory, 'outdated-cli-suppressions'); - const ownerFileName = `.operation-lock-owner-${Date.now()}-${exitedProcessId}-0`; - const ownerPath = path.join(storageDirectory, ownerFileName); - fs.mkdirSync(storageDirectory, { recursive: true }); - fs.writeFileSync(ownerPath, ownerFileName); - fs.linkSync(ownerPath, path.join(storageDirectory, '.operation-lock')); + createAbandonedLock(directory, exitedProcessId); const first = new FileSystemOutdatedCliSuppressionStore(directory); const second = new FileSystemOutdatedCliSuppressionStore(directory); @@ -70,8 +65,34 @@ suite('outdatedCliSuppressionStore', () => { (await first.readAll()).sort(), ['/cli/a\u000013.5.0', '/cli/b\u000013.5.0']); }); + + test('recovers a lock abandoned after cleanup ownership transfers', async () => { + const exitedProcessId = await startAndWaitForProcess(); + const { ownerPath, storageDirectory } = createAbandonedLock(directory, exitedProcessId); + fs.renameSync( + ownerPath, + path.join(storageDirectory, `.operation-lock-recovery-${exitedProcessId}-${Date.now()}-0`)); + + const store = new FileSystemOutdatedCliSuppressionStore(directory); + await store.add('/cli/aspire\u000013.5.0'); + + assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']); + }); }); +function createAbandonedLock( + directory: string, + processId: number, +): { ownerPath: string; storageDirectory: string } { + const storageDirectory = path.join(directory, 'outdated-cli-suppressions'); + const ownerFileName = `.operation-lock-owner-${Date.now()}-${processId}-0`; + const ownerPath = path.join(storageDirectory, ownerFileName); + fs.mkdirSync(storageDirectory, { recursive: true }); + fs.writeFileSync(ownerPath, ownerFileName); + fs.linkSync(ownerPath, path.join(storageDirectory, '.operation-lock')); + return { ownerPath, storageDirectory }; +} + async function startAndWaitForProcess(): Promise { const child = spawn(process.execPath, ['-e', '']); assert.ok(child.pid); diff --git a/extension/src/utils/outdatedCliSuppressionStore.ts b/extension/src/utils/outdatedCliSuppressionStore.ts index a40fc6806a2..4673f6b8bd5 100644 --- a/extension/src/utils/outdatedCliSuppressionStore.ts +++ b/extension/src/utils/outdatedCliSuppressionStore.ts @@ -6,6 +6,7 @@ const suppressionDirectoryName = 'outdated-cli-suppressions'; const suppressionFilePrefix = 'suppression-'; const operationLockFileName = '.operation-lock'; const operationLockOwnerPrefix = '.operation-lock-owner-'; +const operationLockRecoveryPrefix = '.operation-lock-recovery-'; const operationLockRetryIntervalMs = 10; const operationLockAcquireTimeoutMs = 5_000; let suppressionSequence = 0; @@ -144,35 +145,101 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres } private async _recoverAbandonedOperationLock(): Promise { + let ownerFileName: string; try { // The lock file contains its unique owner filename: // .operation-lock-owner-1788280000000-12345-0 - const ownerFileName = await readFile(this._operationLockPath, 'utf8'); + ownerFileName = await readFile(this._operationLockPath, 'utf8'); const match = /^\.operation-lock-owner-\d+-(\d+)-\d+$/.exec(ownerFileName); if (!match || isProcessRunning(Number(match[1]))) { return; } - - try { - // Removing the unique owner link grants one cleaner authority to remove the fixed - // link. Other cleaners see ENOENT and leave any replacement lock untouched. - await unlink(path.join(this._directoryPath, ownerFileName)); + } + catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return; } - catch (error) { + throw error; + } + + const recoveryPath = await this._claimAbandonedOperationLock(ownerFileName); + if (!recoveryPath) { + return; + } + + try { + const currentOwner = await readFile(this._operationLockPath, 'utf8').catch(error => { if (hasErrorCode(error, 'ENOENT')) { - return; + return undefined; } throw error; + }); + if (currentOwner === ownerFileName) { + await unlink(this._operationLockPath); } + } + finally { + await unlink(recoveryPath).catch(error => { + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } + }); + } + } - await unlink(this._operationLockPath); + private async _claimAbandonedOperationLock(ownerFileName: string): Promise { + const generation = `${process.pid}-${Date.now()}-${operationLockSequence++}`; + const recoveryPath = path.join( + this._directoryPath, + `${operationLockRecoveryPrefix}${generation}`); + try { + // Renaming the owner's unique link transfers cleanup authority atomically. If this + // cleaner exits, a later host can transfer the recovery link again. + await rename(path.join(this._directoryPath, ownerFileName), recoveryPath); + return recoveryPath; } catch (error) { - if (hasErrorCode(error, 'ENOENT')) { - return; + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } + } + + const entries = await readdir(this._directoryPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith(operationLockRecoveryPrefix)) { + continue; + } + + const currentRecoveryPath = path.join(this._directoryPath, entry.name); + const recoveredOwner = await readFile(currentRecoveryPath, 'utf8').catch(error => { + if (hasErrorCode(error, 'ENOENT')) { + return undefined; + } + throw error; + }); + if (recoveredOwner !== ownerFileName) { + continue; + } + + // Recovery links are named: + // .operation-lock-recovery--- + const match = /^\.operation-lock-recovery-(\d+)-\d+-\d+$/.exec(entry.name); + if (!match || isProcessRunning(Number(match[1]))) { + return undefined; + } + + try { + await rename(currentRecoveryPath, recoveryPath); + return recoveryPath; + } + catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } } - throw error; } + + return undefined; } private async _releaseOperationLock(ownerPath: string): Promise { From 3c4de2edadd222ff1e22fedb350692f685616568 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 1 Sep 2026 13:33:47 -0700 Subject: [PATCH 26/31] Bound suppression lease identity Expire owner and cleanup leases so reused process IDs cannot permanently wedge CLI warning suppression, and fence expired notification claims before UI dispatch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- .../src/test/outdatedCliNotifier.test.ts | 30 ++++- .../test/outdatedCliSuppressionStore.test.ts | 25 +++- extension/src/utils/outdatedCliNotifier.ts | 15 +++ .../src/utils/outdatedCliSuppressionStore.ts | 114 ++++++++++-------- 4 files changed, 135 insertions(+), 49 deletions(-) diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index 0f6e45cd7fd..4ece6eb2a14 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -87,7 +87,10 @@ suite('outdatedCliNotifier', () => { } function createNotificationClaim(): OutdatedCliNotificationClaim { - return { release: async () => undefined }; + return { + isValid: () => true, + release: async () => undefined, + }; } async function waitFor(predicate: () => boolean, message: string): Promise { @@ -207,6 +210,7 @@ suite('outdatedCliNotifier', () => { readAll: async () => [], add: async () => undefined, tryClaimNotification: async () => ({ + isValid: () => true, release: async () => { claimReleased = true; }, @@ -229,6 +233,30 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); + test('does not warn from an expired cross-window claim', async () => { + let released = false; + const suppressionStore: OutdatedCliSuppressionStore = { + readAll: async () => [], + add: async () => undefined, + tryClaimNotification: async () => ({ + isValid: () => false, + release: async () => { + released = true; + }, + }), + }; + const { notifier, surface, versionProvider } = createNotifier(Date.now, suppressionStore); + + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual(released, true); + assert.deepStrictEqual(surface.warnings, []); + assert.strictEqual(versionProvider.versionCalls.length, 2); + assert.strictEqual(versionProvider.recommendationCalls.length, 2); + notifier.dispose(); + }); + test('uses five-minute version and six-hour update refresh intervals', async () => { let now = 0; const { notifier, versionProvider, surface } = createNotifier(() => now); diff --git a/extension/src/test/outdatedCliSuppressionStore.test.ts b/extension/src/test/outdatedCliSuppressionStore.test.ts index d976cc48773..45717cc2f15 100644 --- a/extension/src/test/outdatedCliSuppressionStore.test.ts +++ b/extension/src/test/outdatedCliSuppressionStore.test.ts @@ -78,14 +78,37 @@ suite('outdatedCliSuppressionStore', () => { assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']); }); + + test('recovers an expired lock whose process ID has been reused', async () => { + createAbandonedLock(directory, process.pid, 0); + + const store = new FileSystemOutdatedCliSuppressionStore(directory); + await store.add('/cli/aspire\u000013.5.0'); + + assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']); + }); + + test('recovers expired cleanup ownership whose process ID has been reused', async () => { + const exitedProcessId = await startAndWaitForProcess(); + const { ownerPath, storageDirectory } = createAbandonedLock(directory, exitedProcessId); + fs.renameSync( + ownerPath, + path.join(storageDirectory, `.operation-lock-recovery-${process.pid}-0-0`)); + + const store = new FileSystemOutdatedCliSuppressionStore(directory); + await store.add('/cli/aspire\u000013.5.0'); + + assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']); + }); }); function createAbandonedLock( directory: string, processId: number, + createdAt = Date.now(), ): { ownerPath: string; storageDirectory: string } { const storageDirectory = path.join(directory, 'outdated-cli-suppressions'); - const ownerFileName = `.operation-lock-owner-${Date.now()}-${processId}-0`; + const ownerFileName = `.operation-lock-owner-${createdAt}-${processId}-0`; const ownerPath = path.join(storageDirectory, ownerFileName); fs.mkdirSync(storageDirectory, { recursive: true }); fs.writeFileSync(ownerPath, ownerFileName); diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index fbf22b90891..509329df434 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -130,6 +130,21 @@ export class OutdatedCliNotifier implements vscode.Disposable { this._persistentlySuppressedCliVersions.add(notificationKey); return; } + try { + if (!claim.isValid()) { + this._notifiedCliVersions.delete(notificationKey); + this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli); + await this._releaseNotificationClaim(claim); + return; + } + } + catch (error) { + this._notifiedCliVersions.delete(notificationKey); + extensionLogOutputChannel.warn(`Unable to validate Aspire CLI update notification claim: ${String(error)}`); + this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli); + await this._releaseNotificationClaim(claim); + return; + } } if (this._disposed) { await this._releaseNotificationClaim(claim); diff --git a/extension/src/utils/outdatedCliSuppressionStore.ts b/extension/src/utils/outdatedCliSuppressionStore.ts index 4673f6b8bd5..963aaafddb7 100644 --- a/extension/src/utils/outdatedCliSuppressionStore.ts +++ b/extension/src/utils/outdatedCliSuppressionStore.ts @@ -9,11 +9,13 @@ const operationLockOwnerPrefix = '.operation-lock-owner-'; const operationLockRecoveryPrefix = '.operation-lock-recovery-'; const operationLockRetryIntervalMs = 10; const operationLockAcquireTimeoutMs = 5_000; +const operationLockLeaseMs = 60_000; let suppressionSequence = 0; let operationLockSequence = 0; /** Holds suppression writes until the warning has been dispatched, but not while awaiting user input. */ export interface OutdatedCliNotificationClaim { + isValid(): boolean; release(): Promise; } @@ -23,6 +25,10 @@ export interface OutdatedCliSuppressionStore { tryClaimNotification(notificationKey: string): Promise; } +function isLeaseCurrent(createdAt: number): boolean { + return Date.now() - createdAt < operationLockLeaseMs; +} + /** * Publishes each suppression as an immutable file so separate extension hosts can add entries * without a cross-window read-modify-write race. A short filesystem lease serializes suppression @@ -43,7 +49,7 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres } async add(notificationKey: string): Promise { - const release = await this._acquireOperationLock(); + const claim = await this._acquireOperationLock(); try { const generation = `${Date.now()}-${process.pid}-${suppressionSequence++}`; const fileName = `${suppressionFilePrefix}${generation}.json`; @@ -54,22 +60,22 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres await rename(temporaryPath, finalPath); } finally { - await release(); + await claim.release(); } } async tryClaimNotification(notificationKey: string): Promise { - const release = await this._acquireOperationLock(); + const claim = await this._acquireOperationLock(); try { if ((await this._readAll()).includes(notificationKey)) { - await release(); + await claim.release(); return undefined; } - return { release }; + return claim; } catch (error) { - await release(); + await claim.release(); throw error; } } @@ -95,12 +101,13 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres return suppressions; } - private async _acquireOperationLock(): Promise<() => Promise> { + private async _acquireOperationLock(): Promise { await mkdir(this._directoryPath, { recursive: true }); const startedAt = Date.now(); while (true) { - const generation = `${Date.now()}-${process.pid}-${operationLockSequence++}`; + const createdAt = Date.now(); + const generation = `${createdAt}-${process.pid}-${operationLockSequence++}`; const ownerFileName = `${operationLockOwnerPrefix}${generation}`; const ownerPath = path.join(this._directoryPath, ownerFileName); await writeFile(ownerPath, ownerFileName, { encoding: 'utf8', flag: 'wx' }); @@ -125,12 +132,15 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres } let released = false; - return async () => { - if (released) { - return; - } - await this._releaseOperationLock(ownerPath); - released = true; + return { + isValid: () => isLeaseCurrent(createdAt), + release: async () => { + if (released) { + return; + } + await this._releaseOperationLock(ownerPath); + released = true; + }, }; } catch (error) { @@ -150,8 +160,9 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres // The lock file contains its unique owner filename: // .operation-lock-owner-1788280000000-12345-0 ownerFileName = await readFile(this._operationLockPath, 'utf8'); - const match = /^\.operation-lock-owner-\d+-(\d+)-\d+$/.exec(ownerFileName); - if (!match || isProcessRunning(Number(match[1]))) { + const match = /^\.operation-lock-owner-(\d+)-(\d+)-\d+$/.exec(ownerFileName); + if (!match || + (isLeaseCurrent(Number(match[1])) && isProcessRunning(Number(match[2])))) { return; } } @@ -167,31 +178,11 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres return; } - try { - const currentOwner = await readFile(this._operationLockPath, 'utf8').catch(error => { - if (hasErrorCode(error, 'ENOENT')) { - return undefined; - } - throw error; - }); - if (currentOwner === ownerFileName) { - await unlink(this._operationLockPath); - } - } - finally { - await unlink(recoveryPath).catch(error => { - if (!hasErrorCode(error, 'ENOENT')) { - throw error; - } - }); - } + await this._removeClaimedOperationLock(ownerFileName, recoveryPath); } private async _claimAbandonedOperationLock(ownerFileName: string): Promise { - const generation = `${process.pid}-${Date.now()}-${operationLockSequence++}`; - const recoveryPath = path.join( - this._directoryPath, - `${operationLockRecoveryPrefix}${generation}`); + const recoveryPath = this._createRecoveryPath(); try { // Renaming the owner's unique link transfers cleanup authority atomically. If this // cleaner exits, a later host can transfer the recovery link again. @@ -223,8 +214,9 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres // Recovery links are named: // .operation-lock-recovery--- - const match = /^\.operation-lock-recovery-(\d+)-\d+-\d+$/.exec(entry.name); - if (!match || isProcessRunning(Number(match[1]))) { + const match = /^\.operation-lock-recovery-(\d+)-(\d+)-\d+$/.exec(entry.name); + if (!match || + (isProcessRunning(Number(match[1])) && isLeaseCurrent(Number(match[2])))) { return undefined; } @@ -243,23 +235,51 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres } private async _releaseOperationLock(ownerPath: string): Promise { + const ownerFileName = path.basename(ownerPath); + const recoveryPath = this._createRecoveryPath(); try { - await unlink(this._operationLockPath); + // Releasing transfers the same cleanup authority used for crash recovery. An expired + // holder therefore cannot remove a replacement lock after another host recovers it. + await rename(ownerPath, recoveryPath); } catch (error) { - if (!hasErrorCode(error, 'ENOENT')) { - throw error; + if (hasErrorCode(error, 'ENOENT')) { + return; } + throw error; } + await this._removeClaimedOperationLock(ownerFileName, recoveryPath); + } + + private async _removeClaimedOperationLock( + ownerFileName: string, + recoveryPath: string, + ): Promise { try { - await unlink(ownerPath); - } - catch (error) { - if (!hasErrorCode(error, 'ENOENT')) { + const currentOwner = await readFile(this._operationLockPath, 'utf8').catch(error => { + if (hasErrorCode(error, 'ENOENT')) { + return undefined; + } throw error; + }); + if (currentOwner === ownerFileName) { + await unlink(this._operationLockPath); } } + finally { + await unlink(recoveryPath).catch(error => { + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } + }); + } + } + + private _createRecoveryPath(): string { + return path.join( + this._directoryPath, + `${operationLockRecoveryPrefix}${process.pid}-${Date.now()}-${operationLockSequence++}`); } } From 64f5ce545b7cf61717ac13f9ec8a0c7d8c749bb1 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 1 Sep 2026 14:01:08 -0700 Subject: [PATCH 27/31] Replace suppression lock with marker handshake Publish notification claims before the final suppression read and publish suppressions before waiting for earlier claims, eliminating stale-writer recovery while preserving cross-window ordering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- .../test/outdatedCliSuppressionStore.test.ts | 49 +-- .../src/utils/outdatedCliSuppressionStore.ts | 314 +++++++----------- 2 files changed, 141 insertions(+), 222 deletions(-) diff --git a/extension/src/test/outdatedCliSuppressionStore.test.ts b/extension/src/test/outdatedCliSuppressionStore.test.ts index 45717cc2f15..44af297612b 100644 --- a/extension/src/test/outdatedCliSuppressionStore.test.ts +++ b/extension/src/test/outdatedCliSuppressionStore.test.ts @@ -50,28 +50,9 @@ suite('outdatedCliSuppressionStore', () => { assert.strictEqual(await second.tryClaimNotification(notificationKey), undefined); }); - test('recovers a lock abandoned by another extension host', async () => { + test('does not wait for a claim abandoned by another extension host', async () => { const exitedProcessId = await startAndWaitForProcess(); - createAbandonedLock(directory, exitedProcessId); - - const first = new FileSystemOutdatedCliSuppressionStore(directory); - const second = new FileSystemOutdatedCliSuppressionStore(directory); - await Promise.all([ - first.add('/cli/a\u000013.5.0'), - second.add('/cli/b\u000013.5.0'), - ]); - - assert.deepStrictEqual( - (await first.readAll()).sort(), - ['/cli/a\u000013.5.0', '/cli/b\u000013.5.0']); - }); - - test('recovers a lock abandoned after cleanup ownership transfers', async () => { - const exitedProcessId = await startAndWaitForProcess(); - const { ownerPath, storageDirectory } = createAbandonedLock(directory, exitedProcessId); - fs.renameSync( - ownerPath, - path.join(storageDirectory, `.operation-lock-recovery-${exitedProcessId}-${Date.now()}-0`)); + createNotificationClaim(directory, '/cli/aspire\u000013.5.0', exitedProcessId); const store = new FileSystemOutdatedCliSuppressionStore(directory); await store.add('/cli/aspire\u000013.5.0'); @@ -79,8 +60,8 @@ suite('outdatedCliSuppressionStore', () => { assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']); }); - test('recovers an expired lock whose process ID has been reused', async () => { - createAbandonedLock(directory, process.pid, 0); + test('does not wait for an expired claim whose process ID has been reused', async () => { + createNotificationClaim(directory, '/cli/aspire\u000013.5.0', process.pid, 0); const store = new FileSystemOutdatedCliSuppressionStore(directory); await store.add('/cli/aspire\u000013.5.0'); @@ -88,32 +69,30 @@ suite('outdatedCliSuppressionStore', () => { assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']); }); - test('recovers expired cleanup ownership whose process ID has been reused', async () => { + test('removes a malformed abandoned claim for another notification', async () => { const exitedProcessId = await startAndWaitForProcess(); - const { ownerPath, storageDirectory } = createAbandonedLock(directory, exitedProcessId); - fs.renameSync( - ownerPath, - path.join(storageDirectory, `.operation-lock-recovery-${process.pid}-0-0`)); + const claimPath = createNotificationClaim(directory, '/cli/other\u000013.5.0', exitedProcessId); + fs.writeFileSync(claimPath, '{'); const store = new FileSystemOutdatedCliSuppressionStore(directory); await store.add('/cli/aspire\u000013.5.0'); + assert.strictEqual(fs.existsSync(claimPath), false); assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']); }); }); -function createAbandonedLock( +function createNotificationClaim( directory: string, + notificationKey: string, processId: number, createdAt = Date.now(), -): { ownerPath: string; storageDirectory: string } { +): string { const storageDirectory = path.join(directory, 'outdated-cli-suppressions'); - const ownerFileName = `.operation-lock-owner-${createdAt}-${processId}-0`; - const ownerPath = path.join(storageDirectory, ownerFileName); fs.mkdirSync(storageDirectory, { recursive: true }); - fs.writeFileSync(ownerPath, ownerFileName); - fs.linkSync(ownerPath, path.join(storageDirectory, '.operation-lock')); - return { ownerPath, storageDirectory }; + const claimPath = path.join(storageDirectory, `notification-claim-${createdAt}-${processId}-0.json`); + fs.writeFileSync(claimPath, JSON.stringify({ notificationKey, processId, createdAt })); + return claimPath; } async function startAndWaitForProcess(): Promise { diff --git a/extension/src/utils/outdatedCliSuppressionStore.ts b/extension/src/utils/outdatedCliSuppressionStore.ts index 963aaafddb7..462f5b6d511 100644 --- a/extension/src/utils/outdatedCliSuppressionStore.ts +++ b/extension/src/utils/outdatedCliSuppressionStore.ts @@ -1,19 +1,16 @@ -import { link, mkdir, readFile, readdir, rename, unlink, writeFile } from 'fs/promises'; +import { mkdir, readFile, readdir, rename, unlink, writeFile } from 'fs/promises'; import * as path from 'path'; import { setTimeout as delay } from 'timers/promises'; const suppressionDirectoryName = 'outdated-cli-suppressions'; const suppressionFilePrefix = 'suppression-'; -const operationLockFileName = '.operation-lock'; -const operationLockOwnerPrefix = '.operation-lock-owner-'; -const operationLockRecoveryPrefix = '.operation-lock-recovery-'; -const operationLockRetryIntervalMs = 10; -const operationLockAcquireTimeoutMs = 5_000; -const operationLockLeaseMs = 60_000; -let suppressionSequence = 0; -let operationLockSequence = 0; +const notificationClaimFilePrefix = 'notification-claim-'; +const markerFileSuffix = '.json'; +const notificationClaimRetryIntervalMs = 10; +const notificationClaimLeaseMs = 60_000; +let markerSequence = 0; -/** Holds suppression writes until the warning has been dispatched, but not while awaiting user input. */ +/** Represents a published claim that remains valid until its bounded lease expires. */ export interface OutdatedCliNotificationClaim { isValid(): boolean; release(): Promise; @@ -25,49 +22,59 @@ export interface OutdatedCliSuppressionStore { tryClaimNotification(notificationKey: string): Promise; } -function isLeaseCurrent(createdAt: number): boolean { - return Date.now() - createdAt < operationLockLeaseMs; +interface NotificationClaimMarker { + notificationKey?: unknown; + processId?: unknown; + createdAt?: unknown; } /** - * Publishes each suppression as an immutable file so separate extension hosts can add entries - * without a cross-window read-modify-write race. A short filesystem lease serializes suppression - * writes with the final notification claim, closing the gap between the last read and showing UI. + * Uses immutable markers to order notifications and suppressions across extension hosts. A claim is + * published before its final suppression read. A suppression is published before it waits for older + * claims to dispatch, so it cannot complete before a warning that already passed the final read. */ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppressionStore { private readonly _directoryPath: string; - private readonly _operationLockPath: string; constructor(globalStoragePath: string) { this._directoryPath = path.join(globalStoragePath, suppressionDirectoryName); - this._operationLockPath = path.join(this._directoryPath, operationLockFileName); } async readAll(): Promise { await mkdir(this._directoryPath, { recursive: true }); - return await this._readAll(); + return await this._readAllSuppressions(); } async add(notificationKey: string): Promise { - const claim = await this._acquireOperationLock(); - try { - const generation = `${Date.now()}-${process.pid}-${suppressionSequence++}`; - const fileName = `${suppressionFilePrefix}${generation}.json`; - const temporaryPath = path.join(this._directoryPath, `.${fileName}.tmp`); - const finalPath = path.join(this._directoryPath, fileName); - - await writeFile(temporaryPath, JSON.stringify(notificationKey), { encoding: 'utf8', flag: 'wx' }); - await rename(temporaryPath, finalPath); - } - finally { - await claim.release(); - } + await this._publishMarker(suppressionFilePrefix, notificationKey); + await this._waitForNotificationClaims(notificationKey); } async tryClaimNotification(notificationKey: string): Promise { - const claim = await this._acquireOperationLock(); + const createdAt = Date.now(); + const claimPath = await this._publishMarker(notificationClaimFilePrefix, { + notificationKey, + processId: process.pid, + createdAt, + }); + let released = false; + const claim: OutdatedCliNotificationClaim = { + isValid: () => isLeaseCurrent(createdAt), + release: async () => { + if (released) { + return; + } + await unlink(claimPath).catch(error => { + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } + }); + released = true; + }, + }; + try { - if ((await this._readAll()).includes(notificationKey)) { + if ((await this._readAllSuppressions()).includes(notificationKey)) { await claim.release(); return undefined; } @@ -80,18 +87,21 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres } } - private async _readAll(): Promise { + private async _readAllSuppressions(): Promise { const entries = await readdir(this._directoryPath, { withFileTypes: true }); const suppressions: string[] = []; for (const entry of entries) { - if (!entry.isFile() || !entry.name.startsWith(suppressionFilePrefix) || !entry.name.endsWith('.json')) { + if (!entry.isFile() || + !entry.name.startsWith(suppressionFilePrefix) || + !entry.name.endsWith(markerFileSuffix)) { continue; } - // Each marker contains a JSON string: + // Each suppression marker contains a JSON string: // "C:\\tools\\aspire.exe\u000013.5.0" - const notificationKey = JSON.parse(await readFile(path.join(this._directoryPath, entry.name), 'utf8')) as unknown; + const notificationKey = JSON.parse( + await readFile(path.join(this._directoryPath, entry.name), 'utf8')) as unknown; if (typeof notificationKey !== 'string') { throw new Error(`Invalid Aspire CLI warning suppression file: ${entry.name}`); } @@ -101,186 +111,108 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres return suppressions; } - private async _acquireOperationLock(): Promise { - await mkdir(this._directoryPath, { recursive: true }); - const startedAt = Date.now(); - + private async _waitForNotificationClaims(notificationKey: string): Promise { while (true) { - const createdAt = Date.now(); - const generation = `${createdAt}-${process.pid}-${operationLockSequence++}`; - const ownerFileName = `${operationLockOwnerPrefix}${generation}`; - const ownerPath = path.join(this._directoryPath, ownerFileName); - await writeFile(ownerPath, ownerFileName, { encoding: 'utf8', flag: 'wx' }); - try { + let hasActiveClaim = false; + const entries = await readdir(this._directoryPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || + !entry.name.startsWith(notificationClaimFilePrefix) || + !entry.name.endsWith(markerFileSuffix)) { + continue; + } + + const claimPath = path.join(this._directoryPath, entry.name); + const markerIdentity = getNotificationClaimIdentity(entry.name); + // Claim markers contain: + // { "notificationKey": "\\u0000", + // "processId": 12345, "createdAt": 1788280000000 } + const contents = await readFile(claimPath, 'utf8').catch(error => { + if (hasErrorCode(error, 'ENOENT')) { + return undefined; + } + throw error; + }); + if (contents === undefined) { + continue; + } + let claim: NotificationClaimMarker | null = null; try { - // The fixed path and unique owner path become hard links to the same file. - // Creating the fixed link is the single atomic cross-process claim operation. - await link(ownerPath, this._operationLockPath); + claim = JSON.parse(contents) as NotificationClaimMarker | null; } catch (error) { - if (!hasErrorCode(error, 'EEXIST')) { + if (!(error instanceof SyntaxError)) { throw error; } - await unlink(ownerPath); - await this._recoverAbandonedOperationLock(); + } - if (Date.now() - startedAt >= operationLockAcquireTimeoutMs) { - throw new Error('Timed out acquiring the Aspire CLI warning suppression lock.'); + if (claim === null || + !markerIdentity || + typeof claim.notificationKey !== 'string' || + claim.processId !== markerIdentity.processId || + claim.createdAt !== markerIdentity.createdAt) { + if (markerIdentity && + isLeaseCurrent(markerIdentity.createdAt) && + isProcessRunning(markerIdentity.processId)) { + hasActiveClaim = true; + } else { + await removeFileIfPresent(claimPath); } - await delay(operationLockRetryIntervalMs); continue; } - let released = false; - return { - isValid: () => isLeaseCurrent(createdAt), - release: async () => { - if (released) { - return; - } - await this._releaseOperationLock(ownerPath); - released = true; - }, - }; - } - catch (error) { - await unlink(ownerPath).catch(error => { - if (!hasErrorCode(error, 'ENOENT')) { - throw error; + if (isLeaseCurrent(claim.createdAt) && isProcessRunning(claim.processId)) { + if (claim.notificationKey === notificationKey) { + hasActiveClaim = true; } - }); - throw error; - } - } - } + continue; + } - private async _recoverAbandonedOperationLock(): Promise { - let ownerFileName: string; - try { - // The lock file contains its unique owner filename: - // .operation-lock-owner-1788280000000-12345-0 - ownerFileName = await readFile(this._operationLockPath, 'utf8'); - const match = /^\.operation-lock-owner-(\d+)-(\d+)-\d+$/.exec(ownerFileName); - if (!match || - (isLeaseCurrent(Number(match[1])) && isProcessRunning(Number(match[2])))) { - return; + await removeFileIfPresent(claimPath); } - } - catch (error) { - if (hasErrorCode(error, 'ENOENT')) { + + if (!hasActiveClaim) { return; } - throw error; + await delay(notificationClaimRetryIntervalMs); } - - const recoveryPath = await this._claimAbandonedOperationLock(ownerFileName); - if (!recoveryPath) { - return; - } - - await this._removeClaimedOperationLock(ownerFileName, recoveryPath); } - private async _claimAbandonedOperationLock(ownerFileName: string): Promise { - const recoveryPath = this._createRecoveryPath(); + private async _publishMarker(prefix: string, value: unknown): Promise { + await mkdir(this._directoryPath, { recursive: true }); + const generation = `${Date.now()}-${process.pid}-${markerSequence++}`; + const fileName = `${prefix}${generation}${markerFileSuffix}`; + const temporaryPath = path.join(this._directoryPath, `.${fileName}.tmp`); + const finalPath = path.join(this._directoryPath, fileName); + + await writeFile(temporaryPath, JSON.stringify(value), { encoding: 'utf8', flag: 'wx' }); try { - // Renaming the owner's unique link transfers cleanup authority atomically. If this - // cleaner exits, a later host can transfer the recovery link again. - await rename(path.join(this._directoryPath, ownerFileName), recoveryPath); - return recoveryPath; + await rename(temporaryPath, finalPath); } catch (error) { - if (!hasErrorCode(error, 'ENOENT')) { - throw error; - } - } - - const entries = await readdir(this._directoryPath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile() || !entry.name.startsWith(operationLockRecoveryPrefix)) { - continue; - } - - const currentRecoveryPath = path.join(this._directoryPath, entry.name); - const recoveredOwner = await readFile(currentRecoveryPath, 'utf8').catch(error => { - if (hasErrorCode(error, 'ENOENT')) { - return undefined; + await unlink(temporaryPath).catch(cleanupError => { + if (!hasErrorCode(cleanupError, 'ENOENT')) { + throw cleanupError; } - throw error; }); - if (recoveredOwner !== ownerFileName) { - continue; - } - - // Recovery links are named: - // .operation-lock-recovery--- - const match = /^\.operation-lock-recovery-(\d+)-(\d+)-\d+$/.exec(entry.name); - if (!match || - (isProcessRunning(Number(match[1])) && isLeaseCurrent(Number(match[2])))) { - return undefined; - } - - try { - await rename(currentRecoveryPath, recoveryPath); - return recoveryPath; - } - catch (error) { - if (!hasErrorCode(error, 'ENOENT')) { - throw error; - } - } - } - - return undefined; - } - - private async _releaseOperationLock(ownerPath: string): Promise { - const ownerFileName = path.basename(ownerPath); - const recoveryPath = this._createRecoveryPath(); - try { - // Releasing transfers the same cleanup authority used for crash recovery. An expired - // holder therefore cannot remove a replacement lock after another host recovers it. - await rename(ownerPath, recoveryPath); - } - catch (error) { - if (hasErrorCode(error, 'ENOENT')) { - return; - } throw error; } - await this._removeClaimedOperationLock(ownerFileName, recoveryPath); + return finalPath; } +} - private async _removeClaimedOperationLock( - ownerFileName: string, - recoveryPath: string, - ): Promise { - try { - const currentOwner = await readFile(this._operationLockPath, 'utf8').catch(error => { - if (hasErrorCode(error, 'ENOENT')) { - return undefined; - } - throw error; - }); - if (currentOwner === ownerFileName) { - await unlink(this._operationLockPath); - } - } - finally { - await unlink(recoveryPath).catch(error => { - if (!hasErrorCode(error, 'ENOENT')) { - throw error; - } - }); - } - } +function isLeaseCurrent(createdAt: number): boolean { + return Date.now() - createdAt < notificationClaimLeaseMs; +} - private _createRecoveryPath(): string { - return path.join( - this._directoryPath, - `${operationLockRecoveryPrefix}${process.pid}-${Date.now()}-${operationLockSequence++}`); - } +function getNotificationClaimIdentity( + fileName: string, +): { createdAt: number; processId: number } | undefined { + const match = /^notification-claim-(\d+)-(\d+)-\d+\.json$/.exec(fileName); + return match + ? { createdAt: Number(match[1]), processId: Number(match[2]) } + : undefined; } function isProcessRunning(processId: number): boolean { @@ -293,6 +225,14 @@ function isProcessRunning(processId: number): boolean { } } +async function removeFileIfPresent(filePath: string): Promise { + await unlink(filePath).catch(error => { + if (!hasErrorCode(error, 'ENOENT')) { + throw error; + } + }); +} + function hasErrorCode(error: unknown, ...codes: string[]): boolean { const code = error instanceof Error && 'code' in error ? error.code : undefined; return typeof code === 'string' && codes.includes(code); From 6e174f98e19f6a0aacd1864890250499b5c35303 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 1 Sep 2026 14:13:28 -0700 Subject: [PATCH 28/31] Expire claims after clock rollback Treat negative claim age as expired so wall-clock corrections cannot extend cross-window suppression claims beyond their bound. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- .../src/test/outdatedCliSuppressionStore.test.ts | 13 +++++++++++++ extension/src/utils/outdatedCliSuppressionStore.ts | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/extension/src/test/outdatedCliSuppressionStore.test.ts b/extension/src/test/outdatedCliSuppressionStore.test.ts index 44af297612b..b1c0dfbb88a 100644 --- a/extension/src/test/outdatedCliSuppressionStore.test.ts +++ b/extension/src/test/outdatedCliSuppressionStore.test.ts @@ -69,6 +69,19 @@ suite('outdatedCliSuppressionStore', () => { assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']); }); + test('does not wait for a claim timestamped in the future', async () => { + createNotificationClaim( + directory, + '/cli/aspire\u000013.5.0', + process.pid, + Date.now() + 60_000); + + const store = new FileSystemOutdatedCliSuppressionStore(directory); + await store.add('/cli/aspire\u000013.5.0'); + + assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.0']); + }); + test('removes a malformed abandoned claim for another notification', async () => { const exitedProcessId = await startAndWaitForProcess(); const claimPath = createNotificationClaim(directory, '/cli/other\u000013.5.0', exitedProcessId); diff --git a/extension/src/utils/outdatedCliSuppressionStore.ts b/extension/src/utils/outdatedCliSuppressionStore.ts index 462f5b6d511..d1ade9071a3 100644 --- a/extension/src/utils/outdatedCliSuppressionStore.ts +++ b/extension/src/utils/outdatedCliSuppressionStore.ts @@ -203,7 +203,8 @@ export class FileSystemOutdatedCliSuppressionStore implements OutdatedCliSuppres } function isLeaseCurrent(createdAt: number): boolean { - return Date.now() - createdAt < notificationClaimLeaseMs; + const age = Date.now() - createdAt; + return age >= 0 && age < notificationClaimLeaseMs; } function getNotificationClaimIdentity( From e7fcb22d0aadea9c3ee0c5a4083e7771a7afe651 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 1 Sep 2026 14:50:12 -0700 Subject: [PATCH 29/31] Correct Doctor payload example Show the required installed identity channel in the structured CLI update metadata example. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/utils/configInfoProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index 9cd532ebbe6..b822a77acdc 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -836,7 +836,7 @@ export function parseCliUpdateRecommendationOutput(output: string): CliUpdateRec // `aspire doctor --format json` emits: // { "checks": [{ "name": "cli-version", "metadata": { // "currentVersion": "13.5.0", "latestVersion": "13.6.0", - // "latestVersionChannel": "stable" } }] } + // "identityChannel": "stable", "latestVersionChannel": "stable" } }] } // Doctor reports the channel baked into the installed assembly. This keeps local/PR/run builds // silent even when their process environment emulates a published channel. const response = JSON.parse(output.trim()) as { checks?: unknown }; From dd78aff9cf4605d8ccb33d2c26f9b4084fa4b177 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 1 Sep 2026 16:59:18 -0700 Subject: [PATCH 30/31] Report manual restore CLI use Keep activation-time restore silent while reporting the exact CLI for forced restores, including retries queued behind active automatic work. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- .../test/aspirePackageRestoreProvider.test.ts | 142 +++++++++++++++++- .../src/utils/AspirePackageRestoreProvider.ts | 44 ++++-- 2 files changed, 171 insertions(+), 15 deletions(-) diff --git a/extension/src/test/aspirePackageRestoreProvider.test.ts b/extension/src/test/aspirePackageRestoreProvider.test.ts index a0137900cb6..b8df9005161 100644 --- a/extension/src/test/aspirePackageRestoreProvider.test.ts +++ b/extension/src/test/aspirePackageRestoreProvider.test.ts @@ -1,13 +1,17 @@ import * as assert from 'assert'; import { ChildProcessWithoutNullStreams } from 'child_process'; import { EventEmitter } from 'events'; +import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { AspirePackageRestoreProvider } from '../utils/AspirePackageRestoreProvider'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { onDidResolveCliForOperation } from '../utils/cliOperationResolution'; import * as cliProcessModule from '../utils/process/cliProcess'; +import * as workspaceModule from '../utils/workspace'; suite('AspirePackageRestoreProvider', () => { let sandbox: sinon.SinonSandbox; @@ -32,9 +36,11 @@ suite('AspirePackageRestoreProvider', () => { }); return childProcess as unknown as ChildProcessWithoutNullStreams; }); + const resolutions: string[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); try { - await (provider as any)._runRestore(configUri, folder.uri.fsPath, 'aspire.config.json'); + await (provider as any)._runRestore(configUri, folder.uri.fsPath, 'aspire.config.json', false); assert.ok(getAspireCliExecutablePath.calledOnceWith(workspaceFolderCliPathTarget(folder))); assert.ok(spawnStub.calledOnceWith( @@ -42,11 +48,139 @@ suite('AspirePackageRestoreProvider', () => { '/repo/workspace/bin/aspire', ['restore'], sinon.match({ workingDirectory: folder.uri.fsPath }))); + assert.deepStrictEqual(resolutions, []); } finally { + subscription.dispose(); provider.dispose(); } }); + test('reports the exact CLI selected for a manual restore', async () => { + const folder = createWorkspaceFolder('/repo/workspace'); + const configUri = vscode.Uri.file(path.join(folder.uri.fsPath, 'aspire.config.json')); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').withArgs(configUri).returns(folder); + const getAspireCliExecutablePath = sandbox.stub().resolves('/repo/workspace/bin/aspire'); + const provider = new AspirePackageRestoreProvider({ getAspireCliExecutablePath } as unknown as AspireTerminalProvider); + const childProcess = createChildProcess(); + sandbox.stub(cliProcessModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + queueMicrotask(() => { + options?.exitCallback?.(0); + childProcess.emit('close', 0); + }); + return childProcess as unknown as ChildProcessWithoutNullStreams; + }); + const resolutions: Array<{ target: unknown; cliPath: string }> = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution)); + + try { + await (provider as any)._runRestore(configUri, folder.uri.fsPath, 'aspire.config.json', true); + + assert.deepStrictEqual(resolutions, [{ + target: workspaceFolderCliPathTarget(folder), + cliPath: '/repo/workspace/bin/aspire', + }]); + } finally { + subscription.dispose(); + provider.dispose(); + } + }); + + test('runs and reports a manual restore when auto-restore is disabled', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-manual-restore-')); + const folder = createWorkspaceFolder(directory); + const configUri = vscode.Uri.file(path.join(folder.uri.fsPath, 'aspire.config.json')); + fs.writeFileSync(configUri.fsPath, '{}'); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').withArgs(configUri).returns(folder); + sandbox.stub(vscode.workspace, 'getConfiguration').returns({ + get: () => false as T, + } as unknown as vscode.WorkspaceConfiguration); + const getAspireCliExecutablePath = sandbox.stub().resolves('/repo/workspace/bin/aspire'); + const provider = new AspirePackageRestoreProvider({ getAspireCliExecutablePath } as unknown as AspireTerminalProvider); + const childProcess = createChildProcess(); + const spawnStub = sandbox.stub(cliProcessModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + queueMicrotask(() => { + options?.exitCallback?.(0); + childProcess.emit('close', 0); + }); + return childProcess as unknown as ChildProcessWithoutNullStreams; + }); + const resolutions: string[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); + + try { + await (provider as any)._restoreIfChanged(configUri, true, true); + + assert.strictEqual(spawnStub.callCount, 1); + assert.deepStrictEqual(resolutions, ['/repo/workspace/bin/aspire']); + } finally { + subscription.dispose(); + provider.dispose(); + fs.rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + test('preserves a manual restore queued behind an active automatic restore', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-queued-restore-')); + const folder = createWorkspaceFolder(directory); + const configUri = vscode.Uri.file(path.join(folder.uri.fsPath, 'aspire.config.json')); + fs.writeFileSync(configUri.fsPath, '{}'); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').withArgs(configUri).returns(folder); + sandbox.stub(vscode.workspace, 'getConfiguration').returns({ + get: () => true as T, + } as unknown as vscode.WorkspaceConfiguration); + sandbox.stub(workspaceModule, 'findAspireSettingsFiles').resolves([configUri]); + const getAspireCliExecutablePath = sandbox.stub().resolves('/repo/workspace/bin/aspire'); + const provider = new AspirePackageRestoreProvider({ getAspireCliExecutablePath } as unknown as AspireTerminalProvider); + const childProcesses = [createChildProcess(), createChildProcess()]; + const restoreCompletions: Array<() => void> = []; + let signalFirstSpawn!: () => void; + let signalSecondSpawn!: () => void; + const firstSpawned = new Promise(resolve => signalFirstSpawn = resolve); + const secondSpawned = new Promise(resolve => signalSecondSpawn = resolve); + let spawnIndex = 0; + const spawnStub = sandbox.stub(cliProcessModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + const childProcess = childProcesses.shift()!; + restoreCompletions.push(() => { + options?.exitCallback?.(0); + childProcess.emit('close', 0); + }); + (spawnIndex++ === 0 ? signalFirstSpawn : signalSecondSpawn)(); + return childProcess as unknown as ChildProcessWithoutNullStreams; + }); + const resolutions: string[] = []; + const subscription = onDidResolveCliForOperation(resolution => resolutions.push(resolution.cliPath)); + let signalQueued!: () => void; + const queued = new Promise(resolve => signalQueued = resolve); + const pendingRestore = provider['_pendingRestore']; + const setPendingRestore = pendingRestore.set.bind(pendingRestore); + sandbox.stub(pendingRestore, 'set').callsFake((key, value) => { + const result = setPendingRestore(key, value); + signalQueued(); + return result; + }); + + try { + const automaticRestore = (provider as any)._restoreIfChanged(configUri, true, false) as Promise; + await firstSpawned; + const manualRestore = provider.retryRestore(); + await queued; + + restoreCompletions.shift()?.(); + await secondSpawned; + restoreCompletions.shift()?.(); + await Promise.all([automaticRestore, manualRestore]); + + assert.strictEqual(spawnStub.callCount, 2); + assert.deepStrictEqual(resolutions, ['/repo/workspace/bin/aspire']); + assert.strictEqual(provider['_completed'], 2); + assert.strictEqual(provider['_total'], 2); + } finally { + subscription.dispose(); + provider.dispose(); + fs.rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + test('does not spawn restore when disposed during CLI resolution', async () => { let resolveCliPath!: (cliPath: string) => void; const cliPath = new Promise(resolve => { @@ -64,7 +198,11 @@ suite('AspirePackageRestoreProvider', () => { }); const configUri = vscode.Uri.file('/repo/workspace/aspire.config.json'); - const restore = (provider as any)._runRestore(configUri, '/repo/workspace', 'aspire.config.json') as Promise; + const restore = (provider as any)._runRestore( + configUri, + '/repo/workspace', + 'aspire.config.json', + false) as Promise; assert.ok(getAspireCliExecutablePath.calledOnce); provider.dispose(); resolveCliPath('/repo/workspace/bin/aspire'); diff --git a/extension/src/utils/AspirePackageRestoreProvider.ts b/extension/src/utils/AspirePackageRestoreProvider.ts index 6ce30e3e9f7..46766bdbe68 100644 --- a/extension/src/utils/AspirePackageRestoreProvider.ts +++ b/extension/src/utils/AspirePackageRestoreProvider.ts @@ -6,6 +6,7 @@ import { ChildProcessWithoutNullStreams } from 'child_process'; import { spawnCliProcess } from './process/cliProcess'; import { AspireTerminalProvider } from './AspireTerminalProvider'; import { getCliPathTargetForUri } from './cliPathVariables'; +import { reportCliResolvedForOperation } from './cliOperationResolution'; import { extensionLogOutputChannel } from './logging'; import { getEnableAutoRestore } from './settings'; import { runningAspireRestore, runningAspireRestoreProgress, aspireRestoreCompleted, aspireRestoreAllCompleted, aspireRestoreFailed, aspireRestoreFailedStatusBar } from '../loc/strings'; @@ -26,7 +27,7 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { private readonly _active = new Map(); // configDir → relativePath private readonly _childProcesses = new Set(); private readonly _timeouts = new Set>(); - private readonly _pendingRestore = new Set(); // configDirs needing re-restore + private readonly _pendingRestore = new Map(); // configDir → force restore private readonly _failedDirs = new Set(); // configDirs that failed private _total = 0; private _completed = 0; @@ -80,13 +81,13 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { return; } - this._total = configs.length; + this._total = this._active.size + configs.length; this._completed = 0; this._failedDirs.clear(); const pending = new Set>(); for (const uri of configs) { - const p = this._restoreIfChanged(uri, true).finally(() => pending.delete(p)); + const p = this._restoreIfChanged(uri, true, force).finally(() => pending.delete(p)); pending.add(p); if (pending.size >= AspirePackageRestoreProvider._maxConcurrency) { await Promise.race(pending); @@ -124,11 +125,15 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { this._total++; } } - await this._restoreIfChanged(uri, false); + await this._restoreIfChanged(uri, false, false); } - private async _restoreIfChanged(uri: vscode.Uri, isInitial: boolean): Promise { - if (this._disposed || !getEnableAutoRestore()) { + private async _restoreIfChanged( + uri: vscode.Uri, + isInitial: boolean, + force: boolean, + ): Promise { + if (this._disposed || (!force && !getEnableAutoRestore())) { return; } @@ -144,7 +149,7 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { } const prev = this._lastContent.get(uri.fsPath); - if (!isInitial && prev === content) { + if (!force && !isInitial && prev === content) { this._completed++; this._showProgress(); this._scheduleHide(); @@ -157,12 +162,14 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { // Queue re-restore if one is already active for this config directory if (this._active.has(configDir)) { - this._pendingRestore.add(configDir); + this._pendingRestore.set( + configDir, + (this._pendingRestore.get(configDir) ?? false) || force); return; } try { - await this._runRestore(uri, configDir, relativePath); + await this._runRestore(uri, configDir, relativePath, force); if (this._disposed) { return; } @@ -181,12 +188,19 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { } // If a change arrived while we were restoring, re-read and restore again - while (!this._disposed && this._pendingRestore.delete(configDir)) { - await this._restoreIfChanged(uri, false); + while (!this._disposed && this._pendingRestore.has(configDir)) { + const reportPendingCliUse = this._pendingRestore.get(configDir) ?? false; + this._pendingRestore.delete(configDir); + await this._restoreIfChanged(uri, false, reportPendingCliUse); } } - private async _runRestore(uri: vscode.Uri, configDir: string, relativePath: string): Promise { + private async _runRestore( + uri: vscode.Uri, + configDir: string, + relativePath: string, + reportCliUse: boolean, + ): Promise { if (this._disposed) { return; } @@ -195,10 +209,14 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { this._showProgress(); try { - const cliPath = await this._terminalProvider.getAspireCliExecutablePath(getCliPathTargetForUri(uri)); + const target = getCliPathTargetForUri(uri); + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(target); if (this._disposed) { return; } + if (reportCliUse) { + reportCliResolvedForOperation(target, cliPath); + } await new Promise((resolve, reject) => { let settled = false; const proc = spawnCliProcess(this._terminalProvider, cliPath, ['restore'], { From 752fc2e215e9edba164ea1d1042b8a30df817686 Mon Sep 17 00:00:00 2001 From: Ella Hathaway Date: Tue, 1 Sep 2026 17:41:29 -0700 Subject: [PATCH 31/31] Track physical CLI replacements Include bounded executable metadata in CLI identity so same-version replacements refresh update recommendations and invalidate stale warning actions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 06ff4fb7-3a0a-4a61-8653-8345b646778c --- extension/src/test/configInfoProvider.test.ts | 37 +++++++++++++ .../src/test/outdatedCliNotifier.test.ts | 53 ++++++++++++++++++- extension/src/utils/configInfoProvider.ts | 50 ++++++++++++++++- extension/src/utils/outdatedCliNotifier.ts | 20 ++++--- 4 files changed, 150 insertions(+), 10 deletions(-) diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts index 37ad3959c1b..9a942fa5399 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -4,6 +4,9 @@ import { EventEmitter } from 'events'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import type { ChildProcessWithoutNullStreams } from 'child_process'; +import { mkdtemp, rename, rm, writeFile } from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; import { ConfigInfoProvider, getConfigInfo, parseCliUpdateRecommendationOutput, parseConfigInfoOutput } from '../utils/configInfoProvider'; import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import * as cliModule from '../utils/process/cliProcess'; @@ -396,6 +399,40 @@ suite('configInfoProvider tests', () => { assert.strictEqual(showErrorMessage.callCount, 0); }); + test('getCliVersion identifies an executable replaced with the same version', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'aspire-cli-version-')); + const cliPath = path.join(directory, 'aspire'); + const replacementPath = path.join(directory, 'replacement'); + await writeFile(cliPath, 'first executable'); + const terminalProvider = { + getAspireCliExecutablePath: async () => cliPath, + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { + assert.strictEqual(command, cliPath); + assert.deepStrictEqual(args, ['--version']); + options?.stdoutCallback?.('13.5.0'); + options?.exitCallback?.(0); + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + try { + const first = await provider.getCliVersion({ cliPath }); + await writeFile(replacementPath, 'replacement executable'); + await rename(replacementPath, cliPath); + const second = await provider.getCliVersion({ cliPath }); + + assert.strictEqual(first?.version, '13.5.0'); + assert.strictEqual(second?.version, '13.5.0'); + assert.notStrictEqual(first?.executableIdentity, second?.executableIdentity); + assert.strictEqual(spawnStub.callCount, 2); + } + finally { + await rm(directory, { recursive: true, force: true }); + } + }); + test('version and update probes do not settle before cancellation termination completes', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => '/unused/aspire', diff --git a/extension/src/test/outdatedCliNotifier.test.ts b/extension/src/test/outdatedCliNotifier.test.ts index 4ece6eb2a14..84f5e0de2ed 100644 --- a/extension/src/test/outdatedCliNotifier.test.ts +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -16,10 +16,13 @@ import { } from '../utils/outdatedCliSuppressionStore'; suite('outdatedCliNotifier', () => { + const defaultExecutableIdentity = 'identity-1'; + class FakeVersionProvider { identity: CliVersionInfo | null = { cliPath: '/cli/aspire', version: '13.5.0', + executableIdentity: defaultExecutableIdentity, }; identityPromise: Promise | undefined; currentVersion: CliVersionInfo | null | undefined; @@ -113,6 +116,7 @@ suite('outdatedCliNotifier', () => { versionProvider.identity = { cliPath: '/workspace/a/.aspire/bin/aspire', version: '13.4.0', + executableIdentity: defaultExecutableIdentity, }; versionProvider.recommendation = { status: 'available', @@ -308,6 +312,7 @@ suite('outdatedCliNotifier', () => { versionProvider.identity = { cliPath: '/cli/aspire', version: '13.5.1', + executableIdentity: 'identity-2', }; versionProvider.recommendation = { status: 'none', @@ -319,6 +324,33 @@ suite('outdatedCliNotifier', () => { notifier.dispose(); }); + test('refreshes the recommendation when the executable changes without a version change', async () => { + let now = 0; + const { notifier, versionProvider, surface } = createNotifier(() => now); + versionProvider.recommendation = { + status: 'ineligible', + currentVersion: '13.5.0', + }; + + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + now = 5 * 60 * 1_000; + versionProvider.identity = { + cliPath: '/cli/aspire', + version: '13.5.0', + executableIdentity: 'identity-2', + }; + versionProvider.recommendation = { + status: 'available', + currentVersion: '13.5.0', + version: '13.6.0', + }; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual(versionProvider.recommendationCalls.length, 2); + assert.strictEqual(surface.warnings.length, 1); + notifier.dispose(); + }); + test('coalesces same-path checks and serializes distinct doctors', async () => { const versionProvider = new FakeVersionProvider(); let activeVersions = 0; @@ -333,6 +365,7 @@ suite('outdatedCliNotifier', () => { resolve({ cliPath: options?.cliPath ?? '/cli/aspire', version: '13.5.0', + executableIdentity: options?.cliPath ?? defaultExecutableIdentity, }); })); }; @@ -392,6 +425,7 @@ suite('outdatedCliNotifier', () => { versionProvider.identity = { cliPath: '/shared/aspire', version: '13.5.0', + executableIdentity: defaultExecutableIdentity, }; versionProvider.getCliUpdateRecommendation = async options => { versionProvider.recommendationCalls.push(options); @@ -432,6 +466,7 @@ suite('outdatedCliNotifier', () => { versionProvider.identity = { cliPath: '/shared/aspire', version: '13.5.0', + executableIdentity: defaultExecutableIdentity, }; versionProvider.getCliUpdateRecommendation = async options => { versionProvider.recommendationCalls.push(options); @@ -465,6 +500,7 @@ suite('outdatedCliNotifier', () => { versionProvider.identity = { cliPath: '/cli/aspire', version: '13.7.0-preview.1', + executableIdentity: defaultExecutableIdentity, }; versionProvider.recommendation = { status: 'available', @@ -494,12 +530,14 @@ suite('outdatedCliNotifier', () => { releaseOlderProbe = () => resolve({ cliPath: '/cli/aspire', version: '13.5.0', + executableIdentity: defaultExecutableIdentity, }); }); } return { cliPath: '/cli/aspire', - version: '13.5.1', + version: '13.5.0', + executableIdentity: 'identity-2', }; }; const backgroundCheck = notifier.notifyIfOutdated( @@ -518,6 +556,19 @@ suite('outdatedCliNotifier', () => { assert.strictEqual(versionProvider.versionCalls.length, 2); assert.deepStrictEqual(surface.commands, []); + surface.selectionPromise = undefined; + surface.selection = undefined; + await notifier.notifyIfOutdated( + workspaceFolderCliPathTarget({ + uri: vscode.Uri.file('/replacement'), + name: 'replacement', + index: 0, + }), + '/cli/aspire'); + assert.strictEqual(surface.warnings.length, 2); + assert.strictEqual(versionProvider.versionCalls.length, 3); + assert.deepStrictEqual(surface.commands, []); + const second = createNotifier(); second.surface.selection = strings.updateAspireCliAction; second.versionProvider.currentVersion = null; diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index b822a77acdc..6da5edfaaf1 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import type { ChildProcessWithoutNullStreams } from 'child_process'; +import { stat } from 'fs/promises'; import { AspireTerminalProvider } from './AspireTerminalProvider'; import { spawnCliProcess, terminateCliProcess } from './process/cliProcess'; import { extensionLogOutputChannel } from './logging'; @@ -80,6 +81,7 @@ export type CliUpdateRecommendationOptions = Omit; } @@ -261,7 +265,17 @@ export class ConfigInfoProvider { } const version = await this._probeCliVersion(cliPath, remainingTimeoutMs, options?.cancellationToken); - return version ? { cliPath, version: version.value } : null; + if (!version) { + return null; + } + + const executableIdentity = await getCliExecutableIdentity( + cliPath, + timeoutMs - (Date.now() - startTime), + options?.cancellationToken); + return executableIdentity + ? { cliPath, version: version.value, executableIdentity } + : null; } /** @@ -761,6 +775,38 @@ export class ConfigInfoProvider { } } +async function getCliExecutableIdentity( + cliPath: string, + timeoutMs: number, + cancellationToken?: vscode.CancellationToken, +): Promise { + if (timeoutMs <= 0 || cancellationToken?.isCancellationRequested) { + return undefined; + } + + let timeout: ReturnType | undefined; + let cancellation: vscode.Disposable | undefined; + const callerCompletion = new Promise(resolve => { + timeout = setTimeout(() => resolve(undefined), timeoutMs); + cancellation = cancellationToken?.onCancellationRequested(() => resolve(undefined)); + }); + const fileIdentity = stat(cliPath, { bigint: true }) + // Device/inode detects atomic replacement; size and timestamps also detect in-place rewrites. + .then(value => + `${value.dev}:${value.ino}:${value.mode}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`) + .catch(() => undefined); + + try { + return await Promise.race([fileIdentity, callerCompletion]); + } + finally { + if (timeout) { + clearTimeout(timeout); + } + cancellation?.dispose(); + } +} + function parseCliVersion(value: string): CliVersion | undefined { // `aspire --version` emits a bare semver-like value, for example: // 13.2.0 diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts index 509329df434..b69193e8ad9 100644 --- a/extension/src/utils/outdatedCliNotifier.ts +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -109,11 +109,12 @@ export class OutdatedCliNotifier implements vscode.Disposable { } const notificationKey = getNotificationKey(notification.cli.cliPath, notification.cli.version); - if (this._notifiedCliVersions.has(notificationKey) || + const sessionNotificationKey = getSessionNotificationKey(notification.cli); + if (this._notifiedCliVersions.has(sessionNotificationKey) || this._persistentlySuppressedCliVersions.has(notificationKey)) { return; } - this._notifiedCliVersions.add(notificationKey); + this._notifiedCliVersions.add(sessionNotificationKey); let claim: OutdatedCliNotificationClaim | undefined; if (this._suppressionStore) { @@ -121,7 +122,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { claim = await this._suppressionStore.tryClaimNotification(notificationKey); } catch (error) { - this._notifiedCliVersions.delete(notificationKey); + this._notifiedCliVersions.delete(sessionNotificationKey); extensionLogOutputChannel.warn(`Unable to claim Aspire CLI update notification: ${String(error)}`); this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli); return; @@ -132,14 +133,14 @@ export class OutdatedCliNotifier implements vscode.Disposable { } try { if (!claim.isValid()) { - this._notifiedCliVersions.delete(notificationKey); + this._notifiedCliVersions.delete(sessionNotificationKey); this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli); await this._releaseNotificationClaim(claim); return; } } catch (error) { - this._notifiedCliVersions.delete(notificationKey); + this._notifiedCliVersions.delete(sessionNotificationKey); extensionLogOutputChannel.warn(`Unable to validate Aspire CLI update notification claim: ${String(error)}`); this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli); await this._releaseNotificationClaim(claim); @@ -184,7 +185,7 @@ export class OutdatedCliNotifier implements vscode.Disposable { if (this._disposed) { return; } - if (!currentVersionProbe || currentVersionProbe.version !== notification.cli.version) { + if (!currentVersionProbe || !areCliIdentitiesEqual(currentVersionProbe, notification.cli)) { return; } @@ -400,6 +401,10 @@ function getNotificationKey(cliPath: string, version: string): string { return `${getComparisonKey(path.normalize(cliPath))}\u0000${version}`; } +function getSessionNotificationKey(cli: CliVersionInfo): string { + return `${getNotificationKey(cli.cliPath, cli.version)}\u0000${cli.executableIdentity}`; +} + function getCliCheckKey( target: CliPathResolutionTarget, cliPath: string, @@ -409,7 +414,8 @@ function getCliCheckKey( } function areCliIdentitiesEqual(left: CliVersionInfo | undefined, right: CliVersionInfo): boolean { - return left?.version === right.version; + return left?.version === right.version && + left.executableIdentity === right.executableIdentity; } class AsyncSerialQueue {