diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index eb20267163b..ab766716917 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} at {1} has a newer version available for its current channel: {2}. + 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..0a25c81219a 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} 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", "aspire-vscode.strings.installCliViewAllOptionsDescription": "Open the installation guide (includes the install script)", diff --git a/extension/src/activation/registerCliCommands.ts b/extension/src/activation/registerCliCommands.ts index e65b41e8ab2..2f5596942cf 100644 --- a/extension/src/activation/registerCliCommands.ts +++ b/extension/src/activation/registerCliCommands.ts @@ -28,9 +28,19 @@ import { getCliPathTargetForUri } from '../utils/cliPathVariables'; interface CommandInvocation { readonly target: CliPathResolutionTarget; readonly appHost?: AppHostCommandTarget; + readonly cliPath?: 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, @@ -48,7 +58,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,9 +157,8 @@ async function tryExecuteCommand( try { await withCommandTelemetry(commandName, async () => { const invocation = await prepareInvocation(); - let cliPath = ''; - const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson", "aspire-vscode.updateSelf"]; - if (!cliCheckExcludedCommands.includes(commandName)) { + let cliPath = invocation.cliPath ?? ''; + if (!cliCheckExcludedCommands.has(commandName)) { if (isE2eBridgeEnabled() && process.env.ASPIRE_EXTENSION_E2E_FORCE_CLI_UNAVAILABLE === 'true') { vscode.window.showErrorMessage( cliNotAvailable, @@ -154,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/update.ts b/extension/src/commands/update.ts index 06a96bb76df..0cdacb8eba4 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 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 8e247453a06..1663c31e860 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 }; @@ -1013,6 +1014,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/appHostCliRunner.ts b/extension/src/data/appHostCliRunner.ts index f0fd7ee57ec..cd3d1f50c22 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; @@ -78,14 +79,16 @@ 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(); } + reportCliResolvedForOperation(target, cliPath); const invocationArgs = this.normalizeNoLogoArgs(cliPath, args); return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { 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 8a17c9aeaea..2f32bcac9bf 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -38,6 +38,9 @@ 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'; +import { onDidResolveCliForOperation } from './utils/cliOperationResolution'; +import { FileSystemOutdatedCliSuppressionStore } from './utils/outdatedCliSuppressionStore'; let aspireExtensionContext = new AspireExtensionContext(); @@ -101,6 +104,17 @@ export async function activate(context: vscode.ExtensionContext) { terminalProvider.closeAllOpenAspireTerminals(); const configInfoProvider = new ConfigInfoProvider(terminalProvider); + 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 => { + extensionLogOutputChannel.warn(`Unable to check Aspire CLI version: ${String(error)}`); + }); + })); const appHostDiscoveryService = new AppHostDiscoveryService(terminalProvider, configInfoProvider); context.subscriptions.push(appHostDiscoveryService); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 083df352649..47c4461f15d 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, 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); export const browserLabel = vscode.l10n.t('Browser'); 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/appHostCliRunner.test.ts b/extension/src/test/appHostCliRunner.test.ts index 9481fb39fc0..999e1337b51 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; @@ -150,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', @@ -157,7 +160,10 @@ suite('data/appHostCliRunner tests', () => { assert.strictEqual(getCliPathStub.called, false); assert.strictEqual(spawnStub.firstCall.args[1], '/repo/tools/aspire'); - } finally { + assert.deepStrictEqual(resolutions, ['/repo/tools/aspire']); + } + finally { + subscription.dispose(); runner.dispose(); } }); diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index ce0764f33e1..d1eb7c30b9e 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,31 @@ 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(); 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..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/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 7a0b09014c4..9a942fa5399 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -4,7 +4,10 @@ import { EventEmitter } from 'events'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import type { ChildProcessWithoutNullStreams } from 'child_process'; -import { ConfigInfoProvider, getConfigInfo, parseConfigInfoOutput } from '../utils/configInfoProvider'; +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'; 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,6 +295,182 @@ suite('configInfoProvider tests', () => { assert.deepStrictEqual(spawnStub.secondCall.args[2], ['config', 'info', '--json']); }); + test('parseCliUpdateRecommendationOutput accepts stable and prerelease recommendations', () => { + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + 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' }); + 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. 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: '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: 'ineligible', 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', undefined, 'offline', 'local')), + { status: 'ineligible', currentVersion: '13.6.0-dev' }); + assert.deepStrictEqual(parseCliUpdateRecommendationOutput( + createDoctorVersionOutput('13.5.0', undefined, 'offline')), + { status: 'unavailable' }); + }); + + 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' }]); + assert.strictEqual(options?.workingDirectory, '/captured/workspace'); + 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. Text from another check must not be + // mistaken for the root command rejecting --nologo. + options?.exitCallback?.(1); + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + assert.deepStrictEqual( + await provider.getCliUpdateRecommendation({ + cliPath: '/exact/aspire', + workingDirectory: '/captured/workspace', + }), + { status: 'available', currentVersion: '13.5.0', version: '13.6.0' }); + assert.strictEqual(spawnStub.callCount, 1); + }); + + 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; + 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?.('not json'); + options?.exitCallback?.(0); + } + return {} as ChildProcessWithoutNullStreams; + }); + const showErrorMessage = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const provider = new ConfigInfoProvider(terminalProvider); + + 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('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', + 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 new file mode 100644 index 00000000000..84f5e0de2ed --- /dev/null +++ b/extension/src/test/outdatedCliNotifier.test.ts @@ -0,0 +1,602 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as strings from '../loc/strings'; +import { + CliUpdateRecommendation, + CliUpdateRecommendationOptions, + CliVersionInfo, + CliVersionStatusOptions, +} from '../utils/configInfoProvider'; +import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { OutdatedCliNotificationSurface, OutdatedCliNotifier } from '../utils/outdatedCliNotifier'; +import { + OutdatedCliNotificationClaim, + OutdatedCliSuppressionStore, +} 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; + recommendation: CliUpdateRecommendation = { + status: 'available', + currentVersion: '13.5.0', + version: '13.6.0', + }; + recommendationPromise: Promise | undefined; + readonly versionCalls: Array = []; + readonly recommendationCalls: Array = []; + + async getCliVersion(options?: CliVersionStatusOptions): Promise { + this.versionCalls.push(options); + 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); + } + } + + class FakeSurface implements OutdatedCliNotificationSurface { + 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 this.selectionPromise ?? Promise.resolve(this.selection); + } + + executeCommand(command: string, ...args: unknown[]): Thenable { + this.commands.push({ command, args }); + return Promise.resolve(undefined); + } + } + + function createNotifier(now: () => number = Date.now, suppressionStore?: OutdatedCliSuppressionStore): { + notifier: OutdatedCliNotifier; + versionProvider: FakeVersionProvider; + surface: FakeSurface; + } { + const versionProvider = new FakeVersionProvider(); + const surface = new FakeSurface(); + return { + notifier: new OutdatedCliNotifier(versionProvider, surface, now, suppressionStore), + versionProvider, + surface, + }; + } + + function createSuppressionStore(values = new Set()): OutdatedCliSuppressionStore { + return { + readAll: async () => [...values], + add: async notificationKey => void values.add(notificationKey), + tryClaimNotification: async notificationKey => values.has(notificationKey) + ? undefined + : createNotificationClaim(), + }; + } + + function createNotificationClaim(): OutdatedCliNotificationClaim { + return { + isValid: () => true, + release: async () => undefined, + }; + } + + 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(); + const target = workspaceFolderCliPathTarget({ + uri: vscode.Uri.file('/workspace/a'), + name: 'a', + index: 0, + }); + versionProvider.identity = { + cliPath: '/workspace/a/.aspire/bin/aspire', + version: '13.4.0', + executableIdentity: defaultExecutableIdentity, + }; + versionProvider.recommendation = { + status: 'available', + currentVersion: '13.4.0', + version: '13.5.2', + }; + versionProvider.currentVersion = versionProvider.identity; + surface.selection = strings.updateAspireCliAction; + + await notifier.notifyIfOutdated(target, '/workspace/a/.aspire/bin/aspire'); + await notifier.notifyIfOutdated(target, '/workspace/a/.aspire/bin/aspire'); + + 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: 13.5.2.'); + 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'], + }]); + notifier.dispose(); + }); + + test("Don't Show Again persists for the exact CLI path and version", async () => { + 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(); + + assert.deepStrictEqual(second.surface.warnings, []); + assert.strictEqual(second.versionProvider.versionCalls.length, 1); + assert.strictEqual(second.versionProvider.recommendationCalls.length, 1); + 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 () => ({ + isValid: () => true, + 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('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); + 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.versionCalls.length, 1); + assert.strictEqual(versionProvider.recommendationCalls.length, 1); + + now++; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + assert.strictEqual(versionProvider.versionCalls.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.versionCalls.length, 3); + assert.strictEqual(versionProvider.recommendationCalls.length, 2); + assert.strictEqual(surface.warnings.length, 1); + notifier.dispose(); + }); + + 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, 25]) { + now = minute * 60 * 1_000; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + } + + assert.strictEqual(versionProvider.versionCalls.length, 6); + assert.strictEqual(versionProvider.recommendationCalls.length, 3); + + now = 30 * 60 * 1_000; + versionProvider.identity = { + cliPath: '/cli/aspire', + version: '13.5.1', + executableIdentity: 'identity-2', + }; + versionProvider.recommendation = { + status: 'none', + currentVersion: '13.5.1', + }; + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.strictEqual(versionProvider.recommendationCalls.length, 4); + 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; + let maximumActiveVersions = 0; + const releaseVersions: Array<() => void> = []; + versionProvider.getCliVersion = async options => { + versionProvider.versionCalls.push(options); + activeVersions++; + maximumActiveVersions = Math.max(maximumActiveVersions, activeVersions); + return await new Promise(resolve => releaseVersions.push(() => { + activeVersions--; + resolve({ + cliPath: options?.cliPath ?? '/cli/aspire', + version: '13.5.0', + executableIdentity: options?.cliPath ?? defaultExecutableIdentity, + }); + })); + }; + 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()); + + 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.'); + releaseDoctors.shift()?.(); + 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(); + }); + + 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', + executableIdentity: defaultExecutableIdentity, + }; + 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 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]); + 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', + executableIdentity: defaultExecutableIdentity, + }; + 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 the version probe and Doctor disagree', async () => { + const { notifier, versionProvider, surface } = createNotifier(); + versionProvider.identity = { + cliPath: '/cli/aspire', + version: '13.7.0-preview.1', + executableIdentity: defaultExecutableIdentity, + }; + versionProvider.recommendation = { + status: 'available', + currentVersion: '13.6.0-preview.1', + version: '13.7.0-preview.2', + }; + + await notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + + assert.deepStrictEqual(surface.warnings, []); + notifier.dispose(); + }); + + 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); + + const notification = notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + await waitFor(() => surface.warnings.length === 1, 'Expected warning to open.'); + 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', + executableIdentity: defaultExecutableIdentity, + }); + }); + } + return { + cliPath: '/cli/aspire', + version: '13.5.0', + executableIdentity: 'identity-2', + }; + }; + 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 new Promise(resolve => setImmediate(resolve)); + releaseOlderProbe(); + await Promise.all([notification, backgroundCheck]); + + 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; + await second.notifier.notifyIfOutdated(windowCliPathTarget, '/cli/aspire'); + assert.deepStrictEqual(second.surface.commands, []); + second.notifier.dispose(); + notifier.dispose(); + }); + + test('dispose cancels an active probe and suppresses continuations', async () => { + const versionProvider = new FakeVersionProvider(); + let resolveVersion!: (version: CliVersionInfo | null) => void; + versionProvider.getCliVersion = async options => { + versionProvider.versionCalls.push(options); + return await new Promise(resolve => resolveVersion = resolve); + }; + 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(); + resolveVersion(null); + await notification; + + 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 new file mode 100644 index 00000000000..b1c0dfbb88a --- /dev/null +++ b/extension/src/test/outdatedCliSuppressionStore.test.ts @@ -0,0 +1,119 @@ +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 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('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('does not wait for a claim abandoned by another extension host', async () => { + const exitedProcessId = await startAndWaitForProcess(); + createNotificationClaim(directory, '/cli/aspire\u000013.5.0', exitedProcessId); + + const store = new FileSystemOutdatedCliSuppressionStore(directory); + await store.add('/cli/aspire\u000013.5.0'); + + assert.deepStrictEqual(await store.readAll(), ['/cli/aspire\u000013.5.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'); + + 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); + 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 createNotificationClaim( + directory: string, + notificationKey: string, + processId: number, + createdAt = Date.now(), +): string { + const storageDirectory = path.join(directory, 'outdated-cli-suppressions'); + fs.mkdirSync(storageDirectory, { recursive: true }); + 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 { + 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/test/registerCliCommands.test.ts b/extension/src/test/registerCliCommands.test.ts index 2a920c45c18..f1065ed08dd 100644 --- a/extension/src/test/registerCliCommands.test.ts +++ b/extension/src/test/registerCliCommands.test.ts @@ -20,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; @@ -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; @@ -44,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', @@ -60,10 +65,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 +297,15 @@ suite('registerCliCommands', () => { assert.ok(tryExecuteDoAppHostStub.calledOnceWith(false, undefined, appHostPath, target, '/resolved/aspire')); }); + 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.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 () => { const hasCapabilityStub = sandbox.stub(ConfigInfoProvider.prototype, 'hasCapability').resolves(true); const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); @@ -344,6 +362,24 @@ suite('registerCliCommands', () => { await callbacks.get('aspire-vscode.updateSelf')!(); assert.strictEqual(resolveCliPathStub.called, false); - assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { target: windowCliPathTarget })); + assert.ok(getAspireCliExecutablePathStub.calledOnceWith(windowCliPathTarget)); + assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { + target: windowCliPathTarget, + cliPath: '/resolved/aspire', + })); + }); + + 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.strictEqual(getAspireCliExecutablePathStub.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/test/workspace.test.ts b/extension/src/test/workspace.test.ts index 6b4d33980e9..7ad9de70415 100644 --- a/extension/src/test/workspace.test.ts +++ b/extension/src/test/workspace.test.ts @@ -6,6 +6,7 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { yesLabel } from '../loc/strings'; 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'; @@ -52,6 +53,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/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'], { diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts index 999fb70724f..fe1f6d41b9a 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 }; @@ -168,7 +169,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) { + reportCliResolvedForOperation(target, cliPath); + } const subcommandLine = formatSubcommand(subcommand); assertNoTerminalControlCharacters(cliPath); 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/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index eac36631bc7..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'; @@ -7,10 +8,16 @@ 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; +// 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; 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 & { @@ -58,11 +65,61 @@ 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 type CliUpdateRecommendationOptions = Omit & { + /** Captured Doctor working directory, when the caller must keep cache and process context aligned. */ + workingDirectory?: string; +}; + +export interface CliVersionInfo { + cliPath: string; + version: string; + executableIdentity: string; +} + +export type CliUpdateRecommendation = + | { status: 'available'; currentVersion: string; version: string } + | { status: 'none'; currentVersion: string } + | { status: 'ineligible'; currentVersion: string } + | { status: 'unavailable' }; + +interface CliVersionStatus { + cliPath: string; + version: string; + status: Exclude; +} + interface CliVersion { + value: string; major: number; minor: number; patch: number; isPrerelease: boolean; + 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; } /** @@ -71,14 +128,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(); } /** @@ -188,6 +246,61 @@ export class ConfigInfoProvider { return await this.getCapabilityStatus(capability, options) === 'supported'; } + /** + * Probes the exact resolved Aspire CLI version. The result is intentionally uncached so callers + * observe an executable replaced in place. + */ + async getCliVersion(options?: CliVersionStatusOptions): Promise { + 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; + } + + const version = await this._probeCliVersion(cliPath, remainingTimeoutMs, options?.cancellationToken); + if (!version) { + return null; + } + + const executableIdentity = await getCliExecutableIdentity( + cliPath, + timeoutMs - (Date.now() - startTime), + options?.cancellationToken); + return executableIdentity + ? { cliPath, version: version.value, executableIdentity } + : null; + } + + /** + * 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' }; + } + + const workingDirectory = options?.workingDirectory ?? resolveConfigInfoWorkingDirectory(target); + return await this._fetchCliUpdateRecommendation( + cliPath, + workingDirectory, + 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 +345,102 @@ 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 async _getCliVersionStatus( cliPath: string, minimumVersion: CliVersion, timeoutMs: number, cancellationToken?: vscode.CancellationToken, - ): Promise { - return new Promise((resolve) => { + ): Promise { + 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 async _probeCliVersion( + cliPath: string, + timeoutMs: number, + cancellationToken?: vscode.CancellationToken, + ): Promise { + 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 output = ''; - let outputTooLong = false; + let termination: Promise | undefined; + let stdout = ''; + let stderr = ''; + let stdoutTooLong = false; let settled = false; let timeout: ReturnType | undefined; let cancellation: vscode.Disposable | undefined; - const settle = (result: CapabilityStatus) => { + const settle = (result: CliCaptureResult | null) => { if (settled) { return; } @@ -261,69 +453,67 @@ 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('unavailable'); + extensionLogOutputChannel.warn(`${options.failureMessage}: ${String(error)}`); + settle(null); }; - if (cancellationToken?.isCancellationRequested) { - settle('unavailable'); + const terminateAndSettle = (reason: 'timed-out' | 'cancelled') => { + if (settled || termination) { + return; + } + if (!childProcess) { + settle(null); + return; + } + + const description = `${reason} ${options.description}`; + termination = terminateCliProcess(childProcess, description) + .catch(error => extensionLogOutputChannel.error(`Failed to terminate ${description}: ${String(error)}`)) + .then(() => settle(null)); + }; + if (options.timeoutMs <= 0 || options.cancellationToken?.isCancellationRequested) { + settle(null); return; } timeout = setTimeout(() => { - settle('unavailable'); - 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)}`); - }); - } - }, Math.min(timeoutMs, cliVersionProbeTimeoutMs)); - cancellation = cancellationToken?.onCancellationRequested(() => { - settle('unavailable'); - 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('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 (code !== 0 || outputTooLong) { - settle('unavailable'); + if (termination) { return; } - - const version = parseCliVersion(output); - if (!version) { - settle('unavailable'); + if (stdoutTooLong) { + settle(null); 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(comparison > 0 || (comparison === 0 && (!version.isPrerelease || minimumVersion.isPrerelease)) - ? 'supported' - : 'unsupported'); + settle({ stdout, stderr, exitCode: code }); }, errorCallback: reportUnavailable, - noExtensionVariables: true, }); } catch (error) { @@ -585,11 +775,44 @@ 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 // 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; @@ -601,17 +824,172 @@ 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), 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): CliUpdateRecommendation { + // `aspire doctor --format json` emits: + // { "checks": [{ "name": "cli-version", "metadata": { + // "currentVersion": "13.5.0", "latestVersion": "13.6.0", + // "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 }; + 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 identityChannel = normalizePublishedCliChannel(metadata.identityChannel); + 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'; + 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 { + 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; } export function parseConfigInfoOutput(output: string): ConfigInfo { diff --git a/extension/src/utils/outdatedCliNotifier.ts b/extension/src/utils/outdatedCliNotifier.ts new file mode 100644 index 00000000000..b69193e8ad9 --- /dev/null +++ b/extension/src/utils/outdatedCliNotifier.ts @@ -0,0 +1,437 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import * as strings from '../loc/strings'; +import { + CliUpdateRecommendation, + CliVersionInfo, + compareCliVersionValues, + ConfigInfoProvider, + resolveConfigInfoWorkingDirectory, +} from './configInfoProvider'; +import { CliPathResolutionTarget, getCliPathTargetKey } from './cliPathVariables'; +import { extensionLogOutputChannel } from './logging'; +import { OutdatedCliNotificationClaim, OutdatedCliSuppressionStore } from './outdatedCliSuppressionStore'; +import { getComparisonKey } from './paths/comparison'; + +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; +const maximumUnavailableAttemptsPerIdentity = 3; + +type CliVersionProvider = Pick; + +export interface OutdatedCliNotificationSurface { + showWarning(message: string, ...actions: string[]): Thenable; + executeCommand(command: string, ...args: unknown[]): Thenable; +} + +interface CliCheckState { + identity: CliVersionInfo | undefined; + versionValidUntil: number; + updateStatus: 'complete' | 'ineligible' | 'unavailable' | undefined; + updateValidUntil: number; + failureCount: number; +} + +interface PendingNotification { + target: CliPathResolutionTarget; + cli: CliVersionInfo; + recommendedVersion: string; +} + +const defaultSurface: OutdatedCliNotificationSurface = { + showWarning: (message, ...actions) => vscode.window.showWarningMessage(message, ...actions), + executeCommand: (command, ...args) => vscode.commands.executeCommand(command, ...args), +}; + +/** + * 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 readonly _stateByCheckKey = new Map(); + 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 readonly _versionQueue = new AsyncSerialQueue(); + private readonly _doctorQueue = new AsyncSerialQueue(); + private _disposed = false; + + constructor( + private readonly _versionProvider: CliVersionProvider, + private readonly _surface: OutdatedCliNotificationSurface = defaultSurface, + private readonly _now: () => number = Date.now, + private readonly _suppressionStore?: OutdatedCliSuppressionStore, + ) { + this._persistentlySuppressedCliVersions = new Set(); + } + + async notifyIfOutdated(target: CliPathResolutionTarget, cliPath: string): Promise { + if (this._disposed) { + return; + } + + const workingDirectory = resolveConfigInfoWorkingDirectory(target); + const checkKey = getCliCheckKey(target, cliPath, workingDirectory); + 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; + return; + } + + const checkStartedAt = this._now(); + let probe!: Promise; + probe = this._checkForUpdate( + target, + checkKey, + cliPath, + workingDirectory, + checkStartedAt).finally(() => { + if (this._inFlightByCheckKey.get(checkKey) === probe) { + this._inFlightByCheckKey.delete(checkKey); + } + }); + this._inFlightByCheckKey.set(checkKey, probe); + const notification = await probe; + if (this._disposed || !notification) { + return; + } + + const notificationKey = getNotificationKey(notification.cli.cliPath, notification.cli.version); + const sessionNotificationKey = getSessionNotificationKey(notification.cli); + if (this._notifiedCliVersions.has(sessionNotificationKey) || + this._persistentlySuppressedCliVersions.has(notificationKey)) { + return; + } + this._notifiedCliVersions.add(sessionNotificationKey); + + let claim: OutdatedCliNotificationClaim | undefined; + if (this._suppressionStore) { + try { + claim = await this._suppressionStore.tryClaimNotification(notificationKey); + } + catch (error) { + this._notifiedCliVersions.delete(sessionNotificationKey); + extensionLogOutputChannel.warn(`Unable to claim Aspire CLI update notification: ${String(error)}`); + this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli); + return; + } + if (!claim) { + this._persistentlySuppressedCliVersions.add(notificationKey); + return; + } + try { + if (!claim.isValid()) { + this._notifiedCliVersions.delete(sessionNotificationKey); + this._invalidateRecommendationAfterSuppressionFailure(checkKey, notification.cli); + await this._releaseNotificationClaim(claim); + return; + } + } + catch (error) { + 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); + 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; + } + if (selection === strings.dontShowAgainLabel) { + await this._suppressNotification(notificationKey); + return; + } + if (selection !== strings.updateAspireCliAction) { + return; + } + + // This user-initiated guard intentionally bypasses the five-minute cache. + const currentVersionProbe = await this._getCliVersion( + notification.target, + notification.cli.cliPath, + false); + if (this._disposed) { + return; + } + if (!currentVersionProbe || !areCliIdentitiesEqual(currentVersionProbe, notification.cli)) { + return; + } + + 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 _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, + 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, + cliPath: string, + workingDirectory: string, + checkStartedAt: number, + ): Promise { + const versionProbe = await this._getCliVersion(target, cliPath); + if (this._disposed) { + return undefined; + } + + const now = this._now(); + const previous = this._stateByCheckKey.get(checkKey); + const identity = versionProbe; + if (!identity) { + this._stateByCheckKey.set(checkKey, { + identity: previous?.identity, + versionValidUntil: checkStartedAt + 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: checkStartedAt + versionRefreshIntervalMs, + updateStatus: undefined, + updateValidUntil: 0, + failureCount: 0, + } + : { + identity, + versionValidUntil: checkStartedAt + versionRefreshIntervalMs, + updateStatus: previous?.updateStatus, + updateValidUntil: previous?.updateValidUntil ?? 0, + failureCount: previous?.failureCount ?? 0, + }; + this._stateByCheckKey.set(checkKey, state); + + const notificationKey = getNotificationKey(identity.cliPath, identity.version); + if (this._persistentlySuppressedCliVersions.has(notificationKey)) { + return undefined; + } + + if (!identityChanged && + (state.updateStatus === 'ineligible' || + (state.updateStatus !== undefined && state.updateValidUntil > now))) { + return undefined; + } + + 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; + } + + 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; + } + + 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'; + 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): Promise { + this._persistentlySuppressedCliVersions.add(notificationKey); + + try { + await this._suppressionStore?.add(notificationKey); + } + catch (error) { + extensionLogOutputChannel.warn(`Unable to persist Aspire CLI warning suppression: ${String(error)}`); + } + } + + dispose(): void { + if (this._disposed) { + return; + } + + this._disposed = true; + this._cancellationSource.cancel(); + this._cancellationSource.dispose(); + this._inFlightByCheckKey.clear(); + this._inFlightVersionByCliPath.clear(); + this._stateByCheckKey.clear(); + this._notifiedCliVersions.clear(); + } +} + +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, + workingDirectory: string, +): string { + return `${getCliPathTargetKey(target)}\u0000${getComparisonKey(path.normalize(cliPath))}\u0000${getComparisonKey(path.normalize(workingDirectory))}`; +} + +function areCliIdentitiesEqual(left: CliVersionInfo | undefined, right: CliVersionInfo): boolean { + return left?.version === right.version && + left.executableIdentity === right.executableIdentity; +} + +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(); + } + } +} diff --git a/extension/src/utils/outdatedCliSuppressionStore.ts b/extension/src/utils/outdatedCliSuppressionStore.ts new file mode 100644 index 00000000000..d1ade9071a3 --- /dev/null +++ b/extension/src/utils/outdatedCliSuppressionStore.ts @@ -0,0 +1,240 @@ +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 notificationClaimFilePrefix = 'notification-claim-'; +const markerFileSuffix = '.json'; +const notificationClaimRetryIntervalMs = 10; +const notificationClaimLeaseMs = 60_000; +let markerSequence = 0; + +/** Represents a published claim that remains valid until its bounded lease expires. */ +export interface OutdatedCliNotificationClaim { + isValid(): boolean; + release(): Promise; +} + +export interface OutdatedCliSuppressionStore { + readAll(): Promise; + add(notificationKey: string): Promise; + tryClaimNotification(notificationKey: string): Promise; +} + +interface NotificationClaimMarker { + notificationKey?: unknown; + processId?: unknown; + createdAt?: unknown; +} + +/** + * 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; + + constructor(globalStoragePath: string) { + this._directoryPath = path.join(globalStoragePath, suppressionDirectoryName); + } + + async readAll(): Promise { + await mkdir(this._directoryPath, { recursive: true }); + return await this._readAllSuppressions(); + } + + async add(notificationKey: string): Promise { + await this._publishMarker(suppressionFilePrefix, notificationKey); + await this._waitForNotificationClaims(notificationKey); + } + + async tryClaimNotification(notificationKey: string): Promise { + 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._readAllSuppressions()).includes(notificationKey)) { + await claim.release(); + return undefined; + } + + return claim; + } + catch (error) { + await claim.release(); + throw error; + } + } + + 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(markerFileSuffix)) { + continue; + } + + // 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; + if (typeof notificationKey !== 'string') { + throw new Error(`Invalid Aspire CLI warning suppression file: ${entry.name}`); + } + suppressions.push(notificationKey); + } + + return suppressions; + } + + private async _waitForNotificationClaims(notificationKey: string): Promise { + while (true) { + 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 { + claim = JSON.parse(contents) as NotificationClaimMarker | null; + } + catch (error) { + if (!(error instanceof SyntaxError)) { + throw error; + } + } + + 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); + } + continue; + } + + if (isLeaseCurrent(claim.createdAt) && isProcessRunning(claim.processId)) { + if (claim.notificationKey === notificationKey) { + hasActiveClaim = true; + } + continue; + } + + await removeFileIfPresent(claimPath); + } + + if (!hasActiveClaim) { + return; + } + await delay(notificationClaimRetryIntervalMs); + } + } + + 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 { + await rename(temporaryPath, finalPath); + } + catch (error) { + await unlink(temporaryPath).catch(cleanupError => { + if (!hasErrorCode(cleanupError, 'ENOENT')) { + throw cleanupError; + } + }); + throw error; + } + + return finalPath; + } +} + +function isLeaseCurrent(createdAt: number): boolean { + const age = Date.now() - createdAt; + return age >= 0 && age < notificationClaimLeaseMs; +} + +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 { + try { + process.kill(processId, 0); + return true; + } + catch (error) { + return !hasErrorCode(error, 'ESRCH'); + } +} + +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); +} diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index 6fc22680429..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'; @@ -282,6 +283,8 @@ export async function checkCliAvailableOrRedirect( }); if (result.available) { + reportCliResolvedForOperation(target, 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}`);