diff --git a/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts new file mode 100644 index 0000000000..aff3d4483a --- /dev/null +++ b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- +* Licensed to the .NET Foundation under one or more agreements. +* The .NET Foundation licenses this file to you under the MIT license. +*--------------------------------------------------------------------------------------------*/ + +/** + * Heuristically detects whether an error/installer message indicates the user cancelled or declined an + * elevation/credential prompt. Kept private to this module: the more robust check in CommandExecutor's + * parseVSCodeSudoExecError is preferred everywhere else, and this heuristic does not consider exit code 126. + * "did not grant permission" comes from @vscode/sudo-prompt when the UAC dialog is dismissed on Windows. + */ +export function isUserCancellationMessage(message: string): boolean +{ + return /cancel|user rejected|user denied|password request|did not grant permission/i.test(message); +} + +/** + * Builds the message thrown for a failed `dotnet.uninstall` so the LLM-tool layer (and other callers) can + * surface the real installer failure reason instead of a generic "another install may be in progress". + */ +export function buildUninstallFailureMessage(version: string, result: string): string +{ + const normalized = result.trim(); + const asNumber = Number(normalized); + const detailText = normalized !== '' && Number.isInteger(asNumber) ? `code ${normalized}` : normalized; + const detailSuffix = detailText ? ` (${detailText})` : ''; + return isUserCancellationMessage(normalized) + ? `Uninstall of .NET ${version} was cancelled. A permission/elevation prompt was dismissed or rejected. Retry and accept the prompt to continue.${detailSuffix}` + : `Uninstall of .NET ${version} did not succeed${detailSuffix}. The uninstaller may be blocked by another install in progress or require manual removal.`; +} diff --git a/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts b/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts index deab577c22..031004d079 100644 --- a/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts +++ b/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts @@ -39,6 +39,7 @@ import SuppressedAcquisitionError } from 'vscode-dotnet-runtime-library'; import { settingsInfoContent } from './SettingsInfoContent'; +import { isUserCancellationMessage } from './ErrorMessageUtilities'; /** * Tool name constants matching those in package.json @@ -98,14 +99,6 @@ function textResult(text: string): vscode.LanguageModelToolResult return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(text)]); } -/** - * Heuristically detects whether an error/installer message indicates the user cancelled or declined an - * elevation/credential prompt, so install and uninstall can surface a consistent "retry and accept prompts" hint. - */ -function isUserCancellationMessage(message: string): boolean -{ - return /cancel|user rejected|user denied|password request/i.test(message); -} /** * Builds the minimal IAcquisitionWorkerContext that the stateless VersionUtilities parsing helpers require. diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index b5d7a0e043..1e7262c521 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -92,6 +92,7 @@ import import { InstallTrackerSingleton } from 'vscode-dotnet-runtime-library/dist/Acquisition/InstallTrackerSingleton'; import { EventStreamTaggingDecorator } from 'vscode-dotnet-runtime-library/dist/EventStream/EventStreamTaggingDecorator'; import { dotnetCoreAcquisitionExtensionId } from './DotnetCoreAcquisitionId'; +import { buildUninstallFailureMessage } from './ErrorMessageUtilities'; import { registerLanguageModelTools } from './LanguageModelTools'; import open = require('open'); @@ -863,7 +864,7 @@ ${JSON.stringify(commandContext)}`)); // rethrown consistently when rethrowError is requested (e.g. for the LLM tools). if (result !== '0' && result !== '') { - throw new Error(`Uninstall of .NET ${commandContext.version} did not succeed (code ${result}). The uninstaller may have been cancelled, blocked by another install in progress, or require manual removal.`); + throw new Error(buildUninstallFailureMessage(commandContext.version, result)); } } }, getIssueContext(existingPathConfigWorker)(commandContext?.errorConfiguration, 'uninstall'), commandContext?.requestingExtensionId, workerContext, commandContext?.rethrowError); diff --git a/vscode-dotnet-runtime-extension/src/test/functional/LanguageModelTools.test.ts b/vscode-dotnet-runtime-extension/src/test/functional/LanguageModelTools.test.ts index e094914dfa..e28eedca53 100644 --- a/vscode-dotnet-runtime-extension/src/test/functional/LanguageModelTools.test.ts +++ b/vscode-dotnet-runtime-extension/src/test/functional/LanguageModelTools.test.ts @@ -17,6 +17,7 @@ import MockWindowDisplayWorker } from 'vscode-dotnet-runtime-library'; import * as extension from '../../extension'; +import { buildUninstallFailureMessage } from '../../ErrorMessageUtilities'; import { buildAvailableInstallsSearchContext, computeLinuxPatchMismatchNote, highestPatchInSameFeatureBand, isFullySpecifiedSdkVersion, resolveSdkVersionForInstall, ToolNames } from '../../LanguageModelTools'; const assert: any = chai.assert; @@ -877,6 +878,17 @@ suite('LanguageModelTools Tests', function () assert.isTrue(hasActionableGuidance, 'Error messages should provide actionable guidance'); }).timeout(standardTimeoutTime); + + test('Reports a dismissed elevation prompt as a cancelled uninstall', () => + { + const message = buildUninstallFailureMessage('9.0.314', 'User did not grant permission.'); + + assert.include(message, 'was cancelled'); + assert.include(message, 'Retry and accept the prompt'); + assert.include(message, 'User did not grant permission.'); + assert.notInclude(message, 'Another install may be in progress'); + assert.notInclude(message, 'code User did not grant permission.'); + }).timeout(standardTimeoutTime); }); suite('Enable/Disable Setting', function () diff --git a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts index 2094030e6f..6ec90bf813 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts @@ -617,33 +617,42 @@ Other dependents remain.`)); } let systemInstallPath = ''; + let uninstallResult = ''; try { - context.eventStream.post(new DotnetUninstallStarted(`Attempting to remove .NET ${install.installId}.`)); await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).untrackInstalledVersion(context, install, force); // Note: it's ok not to check live dependents here (though we could) since this will require UAC and extensions do not depend on us to auto-manage admin installs - if (force || await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).installHasNoRegisteredDependentsBesidesId(install, context.installDirectoryProvider, false, context.acquisitionContext.requestingExtensionId ?? '')) + if (!force && !(await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).installHasNoRegisteredDependentsBesidesId(install, context.installDirectoryProvider, false, context.acquisitionContext.requestingExtensionId ?? ''))) + { + context.eventStream.post(new DotnetUninstallSkipped(`Removed reference of ${JSON.stringify(install)}, but did not uninstall .NET ${install.installId}. +Other dependents remain.`)); + return '0'; + } + + context.eventStream.post(new DotnetUninstallStarted(`Attempting to remove .NET ${install.installId}.`)); + const installingVersion = await globalInstallerResolver.getFullySpecifiedVersion(); + const installer: IGlobalInstaller = os.platform() === 'linux' ? + new LinuxGlobalInstaller(context, this.utilityContext, installingVersion) : + new WinMacGlobalInstaller(context, this.utilityContext, installingVersion, await globalInstallerResolver.getInstallerUrl(), await globalInstallerResolver.getInstallerHash()); + + systemInstallPath = await installer.getExpectedGlobalSDKPath(installingVersion, install.architecture); + uninstallResult = await installer.uninstallSDK(install); + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining('dotnet', context); + await new CommandExecutor(context, this.utilityContext).endSudoProcessMaster(context.eventStream); + if (uninstallResult === '0') { - const installingVersion = await globalInstallerResolver.getFullySpecifiedVersion(); - const installer: IGlobalInstaller = os.platform() === 'linux' ? - new LinuxGlobalInstaller(context, this.utilityContext, installingVersion) : - new WinMacGlobalInstaller(context, this.utilityContext, installingVersion, await globalInstallerResolver.getInstallerUrl(), await globalInstallerResolver.getInstallerHash()); - - systemInstallPath = await installer.getExpectedGlobalSDKPath(installingVersion, install.architecture); - const ok = await installer.uninstallSDK(install); - LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining('dotnet', context); - await new CommandExecutor(context, this.utilityContext).endSudoProcessMaster(context.eventStream); - if (ok === '0') - { - await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).reportSuccessfulUninstall(context, install, force); - context.eventStream.post(new DotnetUninstallCompleted(`Uninstalled .NET ${install.installId}.`)); - return '0'; - } + await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).reportSuccessfulUninstall(context, install, force); + context.eventStream.post(new DotnetUninstallCompleted(`Uninstalled .NET ${install.installId}.`)); + return '0'; } - context.eventStream.post(new DotnetUninstallFailed(`Failed to uninstall .NET ${install.installId}. Another install may be in progress? Uninstall manually or delete the folder.`)); - return '117778'; // arbitrary error code to indicate uninstall failed without error. + + // When command execution is non-terminal, the status may contain a useful error from the elevation + // provider (for example, "User did not grant permission.") instead of only a numeric exit code. + const failureReason = uninstallResult.trim(); + context.eventStream.post(new DotnetUninstallFailed(`Failed to uninstall .NET ${install.installId}.${failureReason ? ` ${failureReason}` : ''}`)); + return failureReason || '1'; } catch (error: any) { diff --git a/vscode-dotnet-runtime-library/src/test/unit/DotnetCoreAcquisitionWorker.test.ts b/vscode-dotnet-runtime-library/src/test/unit/DotnetCoreAcquisitionWorker.test.ts index 6cd772d09d..b3c5306c3c 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/DotnetCoreAcquisitionWorker.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/DotnetCoreAcquisitionWorker.test.ts @@ -8,10 +8,14 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { DotnetCoreAcquisitionWorker } from '../../Acquisition/DotnetCoreAcquisitionWorker'; +import { GetDotnetInstallInfo } from '../../Acquisition/DotnetInstall'; import { DotnetInstallMode } from '../../Acquisition/DotnetInstallMode'; +import { GlobalInstallerResolver } from '../../Acquisition/GlobalInstallerResolver'; import { IAcquisitionInvoker } from '../../Acquisition/IAcquisitionInvoker'; import { IAcquisitionWorkerContext } from '../../Acquisition/IAcquisitionWorkerContext'; import { InstallRecord } from '../../Acquisition/InstallRecord'; +import { LinuxGlobalInstaller } from '../../Acquisition/LinuxGlobalInstaller'; +import { WinMacGlobalInstaller } from '../../Acquisition/WinMacGlobalInstaller'; import { IEventStream } from '../../EventStream/EventStream'; import { @@ -22,6 +26,7 @@ import DotnetLockEvent, DotnetUninstallAllCompleted, DotnetUninstallAllStarted, + DotnetUninstallFailed, TestAcquireCalled } from '../../EventStream/EventStreamEvents'; import { EventType } from '../../EventStream/EventType'; @@ -336,6 +341,55 @@ ${eventStream.events.map(event => event.eventName).join(', ')}`); await acquireAndUninstallAll('6.0', 'sdk', 'local'); }).timeout(expectedTimeoutTime); + test('Global uninstall failure surfaces the installer error', async () => + { + const version = '9.0.314'; + const failureReason = 'User did not grant permission.'; + const eventStream = new MockEventStream(); + const context = getMockAcquisitionContext('sdk', version, expectedTimeoutTime, eventStream); + context.acquisitionContext.installType = 'global'; + context.acquisitionContext.requestingExtensionId = 'test.extension'; + const worker = getMockAcquisitionWorker(context); + const install = GetDotnetInstallInfo(version, 'sdk', 'global', os.arch()); + const resolver = { + getFullySpecifiedVersion: async () => version, + getInstallerUrl: async () => 'https://example.invalid/dotnet-sdk.exe', + getInstallerHash: async () => '' + } as GlobalInstallerResolver; + + const installerPrototypes: Array<{ prototype: { getExpectedGlobalSDKPath: any; uninstallSDK: any }; expectedSdkPath: string }> = [ + { prototype: LinuxGlobalInstaller.prototype, expectedSdkPath: `/usr/share/dotnet/sdk/${version}` }, + { prototype: WinMacGlobalInstaller.prototype, expectedSdkPath: `C:\\Program Files\\dotnet\\sdk\\${version}` } + ]; + const restorers = installerPrototypes.map(({ prototype, expectedSdkPath }) => + { + const originalGetPath = prototype.getExpectedGlobalSDKPath; + const originalUninstall = prototype.uninstallSDK; + prototype.getExpectedGlobalSDKPath = async () => expectedSdkPath; + prototype.uninstallSDK = async () => failureReason; + return () => + { + prototype.getExpectedGlobalSDKPath = originalGetPath; + prototype.uninstallSDK = originalUninstall; + }; + }); + + try + { + const result = await worker.uninstallGlobal(context, install, resolver, true); + const failureEvent = eventStream.events.find(event => event instanceof DotnetUninstallFailed) as DotnetUninstallFailed; + + assert.equal(result, failureReason); + assert.exists(failureEvent); + assert.include(failureEvent.eventMessage, failureReason); + assert.notInclude(failureEvent.eventMessage, 'Another install may be in progress'); + } + finally + { + restorers.forEach(restore => restore()); + } + }).timeout(expectedTimeoutTime); + test('Correctly Removes Legacy (No-Architecture) Installs', async () => { const runtimeV5 = '5.0.00';