From 8bc844784446e751304cbdf2665fef26b5c38843 Mon Sep 17 00:00:00 2001 From: GaTTGeng Date: Fri, 19 Jun 2026 09:35:37 +0800 Subject: [PATCH 1/9] Recognize sudo-prompt UAC cancellation in cancellation heuristic When a Windows user dismisses the UAC dialog during global SDK uninstall, @vscode/sudo-prompt surfaces "User did not grant permission." That string matched none of the existing tokens in isUserCancellationMessage, so the LLM tool path classified the cancellation as a generic failure and showed the wrong retry guidance. Broaden the regex to recognize it, and export the helper so the extension command path can reuse it (#2698). --- vscode-dotnet-runtime-extension/src/LanguageModelTools.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts b/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts index deab577c22..c167ac3fb9 100644 --- a/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts +++ b/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts @@ -101,10 +101,11 @@ function textResult(text: string): vscode.LanguageModelToolResult /** * 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. + * "did not grant permission" comes from @vscode/sudo-prompt when the UAC dialog is dismissed on Windows. */ -function isUserCancellationMessage(message: string): boolean +export function isUserCancellationMessage(message: string): boolean { - return /cancel|user rejected|user denied|password request/i.test(message); + return /cancel|user rejected|user denied|password request|did not grant permission/i.test(message); } /** From 43d728607b715e9b1cd6c3065ef65989264027f2 Mon Sep 17 00:00:00 2001 From: GaTTGeng Date: Fri, 19 Jun 2026 09:35:43 +0800 Subject: [PATCH 2/9] Surface installer failure reason from global SDK uninstall uninstallGlobal previously discarded the installer's status string and returned the arbitrary code 117778 with a fixed "Another install may be in progress" message. When the user dismisses the UAC dialog the status already carries "User did not grant permission.", so propagate it into the DotnetUninstallFailed event and the return value instead. Add a unit test that stubs both global installers to assert the surfaced text (#2698). --- .../DotnetCoreAcquisitionWorker.ts | 14 +++-- .../unit/DotnetCoreAcquisitionWorker.test.ts | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts index 2094030e6f..be875521dd 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts @@ -617,6 +617,7 @@ Other dependents remain.`)); } let systemInstallPath = ''; + let uninstallResult = ''; try { @@ -632,18 +633,23 @@ Other dependents remain.`)); 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); + uninstallResult = await installer.uninstallSDK(install); LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining('dotnet', context); await new CommandExecutor(context, this.utilityContext).endSudoProcessMaster(context.eventStream); - if (ok === '0') + if (uninstallResult === '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(); + const failureDetails = failureReason ? ` ${failureReason}` : ''; + context.eventStream.post(new DotnetUninstallFailed(`Failed to uninstall .NET ${install.installId}.${failureDetails}`)); + 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..e96d343257 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,60 @@ ${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 originalLinuxGetExpectedGlobalSDKPath = LinuxGlobalInstaller.prototype.getExpectedGlobalSDKPath; + const originalLinuxUninstallSDK = LinuxGlobalInstaller.prototype.uninstallSDK; + const originalGetExpectedGlobalSDKPath = WinMacGlobalInstaller.prototype.getExpectedGlobalSDKPath; + const originalUninstallSDK = WinMacGlobalInstaller.prototype.uninstallSDK; + const originalDisableMutex = process.env.VSCODE_DOTNET_RUNTIME_DISABLE_MUTEX; + + process.env.VSCODE_DOTNET_RUNTIME_DISABLE_MUTEX = 'true'; + LinuxGlobalInstaller.prototype.getExpectedGlobalSDKPath = async () => `/usr/share/dotnet/sdk/${version}`; + LinuxGlobalInstaller.prototype.uninstallSDK = async () => failureReason; + WinMacGlobalInstaller.prototype.getExpectedGlobalSDKPath = async () => `C:\\Program Files\\dotnet\\sdk\\${version}`; + WinMacGlobalInstaller.prototype.uninstallSDK = async () => failureReason; + + 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 + { + LinuxGlobalInstaller.prototype.getExpectedGlobalSDKPath = originalLinuxGetExpectedGlobalSDKPath; + LinuxGlobalInstaller.prototype.uninstallSDK = originalLinuxUninstallSDK; + WinMacGlobalInstaller.prototype.getExpectedGlobalSDKPath = originalGetExpectedGlobalSDKPath; + WinMacGlobalInstaller.prototype.uninstallSDK = originalUninstallSDK; + if (originalDisableMutex === undefined) + { + delete process.env.VSCODE_DOTNET_RUNTIME_DISABLE_MUTEX; + } + else + { + process.env.VSCODE_DOTNET_RUNTIME_DISABLE_MUTEX = originalDisableMutex; + } + } + }).timeout(expectedTimeoutTime); + test('Correctly Removes Legacy (No-Architecture) Installs', async () => { const runtimeV5 = '5.0.00'; From b837d8ed20680b6895d0b3af6187b443273f93ef Mon Sep 17 00:00:00 2001 From: GaTTGeng Date: Fri, 19 Jun 2026 09:35:49 +0800 Subject: [PATCH 3/9] Tailor uninstall failure message for cancelled elevation prompt The wrapper at the extension command path used to claim every non-zero uninstallGlobal result was "may have been cancelled, blocked by another install in progress, or require manual removal", and embedded the raw result inside "(code ...)". Now that uninstallGlobal forwards the elevation provider's reason string, detect the cancellation case via isUserCancellationMessage and emit a clear "admin/elevation prompt was dismissed" message; for non-numeric reasons drop the "code" wrapping since the result already reads as text (#2698). --- vscode-dotnet-runtime-extension/src/extension.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index b5d7a0e043..640a492722 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -92,7 +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 { registerLanguageModelTools } from './LanguageModelTools'; +import { isUserCancellationMessage, registerLanguageModelTools } from './LanguageModelTools'; import open = require('open'); const packageJson = require('../package.json'); @@ -863,7 +863,16 @@ ${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.`); + // uninstallGlobal may now return a human-readable failure reason from the elevation provider + // (e.g. "User did not grant permission.") instead of only a numeric exit code. Avoid the + // misleading "(code )" wrapping in that case, and tailor the message when the reason + // indicates the user dismissed the admin/credential prompt. + const looksNumeric = /^-?\d+$/.test(result); + const detail = looksNumeric ? `code ${result}` : result; + const message = isUserCancellationMessage(result) + ? `Uninstall of .NET ${commandContext.version} was cancelled — the admin/elevation prompt was dismissed. Retry and accept the prompt to continue. (${detail})` + : `Uninstall of .NET ${commandContext.version} did not succeed (${detail}). The uninstaller may be blocked by another install in progress, or require manual removal.`; + throw new Error(message); } } }, getIssueContext(existingPathConfigWorker)(commandContext?.errorConfiguration, 'uninstall'), commandContext?.requestingExtensionId, workerContext, commandContext?.rethrowError); From cb4ff660fc46b8fac7be20a97bf8abcb8e95e59d Mon Sep 17 00:00:00 2001 From: GaTTGeng Date: Fri, 19 Jun 2026 09:35:55 +0800 Subject: [PATCH 4/9] Test cancelled uninstall message Move uninstall failure formatting into a testable helper and verify that dismissing the elevation prompt is reported as a cancellation rather than an unrelated installer conflict. Fix #2698 --- .../src/LanguageModelTools.ts | 8 ++++++++ vscode-dotnet-runtime-extension/src/extension.ts | 13 ++----------- .../src/test/functional/LanguageModelTools.test.ts | 13 ++++++++++++- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts b/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts index c167ac3fb9..2c590088fd 100644 --- a/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts +++ b/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts @@ -108,6 +108,14 @@ export function isUserCancellationMessage(message: string): boolean return /cancel|user rejected|user denied|password request|did not grant permission/i.test(message); } +export function buildUninstallFailureMessage(version: string, result: string): string +{ + const detail = /^-?\d+$/.test(result) ? `code ${result}` : result; + return isUserCancellationMessage(result) + ? `Uninstall of .NET ${version} was cancelled — the admin/elevation prompt was dismissed. Retry and accept the prompt to continue. (${detail})` + : `Uninstall of .NET ${version} did not succeed (${detail}). The uninstaller may be blocked by another install in progress, or require manual removal.`; +} + /** * Builds the minimal IAcquisitionWorkerContext that the stateless VersionUtilities parsing helpers require. * Those helpers only read `acquisitionContext` (and only when constructing error events for malformed input, diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index 640a492722..8bcc254174 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -92,7 +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 { isUserCancellationMessage, registerLanguageModelTools } from './LanguageModelTools'; +import { buildUninstallFailureMessage, registerLanguageModelTools } from './LanguageModelTools'; import open = require('open'); const packageJson = require('../package.json'); @@ -863,16 +863,7 @@ ${JSON.stringify(commandContext)}`)); // rethrown consistently when rethrowError is requested (e.g. for the LLM tools). if (result !== '0' && result !== '') { - // uninstallGlobal may now return a human-readable failure reason from the elevation provider - // (e.g. "User did not grant permission.") instead of only a numeric exit code. Avoid the - // misleading "(code )" wrapping in that case, and tailor the message when the reason - // indicates the user dismissed the admin/credential prompt. - const looksNumeric = /^-?\d+$/.test(result); - const detail = looksNumeric ? `code ${result}` : result; - const message = isUserCancellationMessage(result) - ? `Uninstall of .NET ${commandContext.version} was cancelled — the admin/elevation prompt was dismissed. Retry and accept the prompt to continue. (${detail})` - : `Uninstall of .NET ${commandContext.version} did not succeed (${detail}). The uninstaller may be blocked by another install in progress, or require manual removal.`; - throw new Error(message); + 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..c3e96b4add 100644 --- a/vscode-dotnet-runtime-extension/src/test/functional/LanguageModelTools.test.ts +++ b/vscode-dotnet-runtime-extension/src/test/functional/LanguageModelTools.test.ts @@ -17,7 +17,7 @@ import MockWindowDisplayWorker } from 'vscode-dotnet-runtime-library'; import * as extension from '../../extension'; -import { buildAvailableInstallsSearchContext, computeLinuxPatchMismatchNote, highestPatchInSameFeatureBand, isFullySpecifiedSdkVersion, resolveSdkVersionForInstall, ToolNames } from '../../LanguageModelTools'; +import { buildAvailableInstallsSearchContext, buildUninstallFailureMessage, computeLinuxPatchMismatchNote, highestPatchInSameFeatureBand, isFullySpecifiedSdkVersion, resolveSdkVersionForInstall, ToolNames } from '../../LanguageModelTools'; const assert: any = chai.assert; const standardTimeoutTime = 30000; @@ -877,6 +877,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 () From 1eea46b091b0d07be55023fce7efeb285b144c41 Mon Sep 17 00:00:00 2001 From: GaTTGeng Date: Sat, 20 Jun 2026 13:45:56 +0800 Subject: [PATCH 5/9] Address PR review feedback from Copilot and nagilson - Fix uninstallGlobal to emit DotnetUninstallSkipped and return '0' when other dependents remain instead of falling through to the failure path (Copilot) - Move buildUninstallFailureMessage into ErrorMessageUtilities.ts to avoid circular dependency between extension.ts and LanguageModelTools (nagilson) - Keep isUserCancellationMessage private in both modules (nagilson) - Trim result before numeric detection and use Number.isInteger instead of regex for readability (Copilot + nagilson) - Remove redundant failureDetails variable (nagilson) - Drop unnecessary VSCODE_DOTNET_RUNTIME_DISABLE_MUTEX env toggle in test (nagilson) - Parameterize prototype patching for Linux/WinMac installers to reduce duplication in regression test (nagilson) --- .../src/ErrorMessageUtilities.ts | 29 +++++++++++++ .../src/LanguageModelTools.ts | 12 ++---- .../src/extension.ts | 3 +- .../functional/LanguageModelTools.test.ts | 3 +- .../DotnetCoreAcquisitionWorker.ts | 41 ++++++++++--------- .../unit/DotnetCoreAcquisitionWorker.test.ts | 41 ++++++++----------- 6 files changed, 76 insertions(+), 53 deletions(-) create mode 100644 vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts diff --git a/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts new file mode 100644 index 0000000000..8735428e25 --- /dev/null +++ b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- +* 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. + */ +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 detail = normalized !== '' && Number.isInteger(asNumber) ? `code ${normalized}` : normalized; + return isUserCancellationMessage(normalized) + ? `Uninstall of .NET ${version} was cancelled — the admin/elevation prompt was dismissed. Retry and accept the prompt to continue. (${detail})` + : `Uninstall of .NET ${version} did not succeed (${detail}). 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 2c590088fd..ffd94f02db 100644 --- a/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts +++ b/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts @@ -101,21 +101,15 @@ function textResult(text: string): vscode.LanguageModelToolResult /** * 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. + * Kept private to this module: it does not consider exit code 126, so callers outside the LM tool path should + * prefer the more robust check in CommandExecutor.parseVSCodeSudoExecError instead. * "did not grant permission" comes from @vscode/sudo-prompt when the UAC dialog is dismissed on Windows. */ -export function isUserCancellationMessage(message: string): boolean +function isUserCancellationMessage(message: string): boolean { return /cancel|user rejected|user denied|password request|did not grant permission/i.test(message); } -export function buildUninstallFailureMessage(version: string, result: string): string -{ - const detail = /^-?\d+$/.test(result) ? `code ${result}` : result; - return isUserCancellationMessage(result) - ? `Uninstall of .NET ${version} was cancelled — the admin/elevation prompt was dismissed. Retry and accept the prompt to continue. (${detail})` - : `Uninstall of .NET ${version} did not succeed (${detail}). The uninstaller may be blocked by another install in progress, or require manual removal.`; -} - /** * Builds the minimal IAcquisitionWorkerContext that the stateless VersionUtilities parsing helpers require. * Those helpers only read `acquisitionContext` (and only when constructing error events for malformed input, diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index 8bcc254174..1e7262c521 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -92,7 +92,8 @@ 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, registerLanguageModelTools } from './LanguageModelTools'; +import { buildUninstallFailureMessage } from './ErrorMessageUtilities'; +import { registerLanguageModelTools } from './LanguageModelTools'; import open = require('open'); const packageJson = require('../package.json'); 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 c3e96b4add..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,7 +17,8 @@ import MockWindowDisplayWorker } from 'vscode-dotnet-runtime-library'; import * as extension from '../../extension'; -import { buildAvailableInstallsSearchContext, buildUninstallFailureMessage, computeLinuxPatchMismatchNote, highestPatchInSameFeatureBand, isFullySpecifiedSdkVersion, resolveSdkVersionForInstall, ToolNames } from '../../LanguageModelTools'; +import { buildUninstallFailureMessage } from '../../ErrorMessageUtilities'; +import { buildAvailableInstallsSearchContext, computeLinuxPatchMismatchNote, highestPatchInSameFeatureBand, isFullySpecifiedSdkVersion, resolveSdkVersionForInstall, ToolNames } from '../../LanguageModelTools'; const assert: any = chai.assert; const standardTimeoutTime = 30000; diff --git a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts index be875521dd..6ec90bf813 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts @@ -621,34 +621,37 @@ Other dependents remain.`)); 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 ?? ''))) { - 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') - { - 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 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') + { + await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).reportSuccessfulUninstall(context, install, force); + context.eventStream.post(new DotnetUninstallCompleted(`Uninstalled .NET ${install.installId}.`)); + return '0'; } // 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(); - const failureDetails = failureReason ? ` ${failureReason}` : ''; - context.eventStream.post(new DotnetUninstallFailed(`Failed to uninstall .NET ${install.installId}.${failureDetails}`)); + 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 e96d343257..b3c5306c3c 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/DotnetCoreAcquisitionWorker.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/DotnetCoreAcquisitionWorker.test.ts @@ -356,17 +356,23 @@ ${eventStream.events.map(event => event.eventName).join(', ')}`); getInstallerUrl: async () => 'https://example.invalid/dotnet-sdk.exe', getInstallerHash: async () => '' } as GlobalInstallerResolver; - const originalLinuxGetExpectedGlobalSDKPath = LinuxGlobalInstaller.prototype.getExpectedGlobalSDKPath; - const originalLinuxUninstallSDK = LinuxGlobalInstaller.prototype.uninstallSDK; - const originalGetExpectedGlobalSDKPath = WinMacGlobalInstaller.prototype.getExpectedGlobalSDKPath; - const originalUninstallSDK = WinMacGlobalInstaller.prototype.uninstallSDK; - const originalDisableMutex = process.env.VSCODE_DOTNET_RUNTIME_DISABLE_MUTEX; - - process.env.VSCODE_DOTNET_RUNTIME_DISABLE_MUTEX = 'true'; - LinuxGlobalInstaller.prototype.getExpectedGlobalSDKPath = async () => `/usr/share/dotnet/sdk/${version}`; - LinuxGlobalInstaller.prototype.uninstallSDK = async () => failureReason; - WinMacGlobalInstaller.prototype.getExpectedGlobalSDKPath = async () => `C:\\Program Files\\dotnet\\sdk\\${version}`; - WinMacGlobalInstaller.prototype.uninstallSDK = async () => failureReason; + + 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 { @@ -380,18 +386,7 @@ ${eventStream.events.map(event => event.eventName).join(', ')}`); } finally { - LinuxGlobalInstaller.prototype.getExpectedGlobalSDKPath = originalLinuxGetExpectedGlobalSDKPath; - LinuxGlobalInstaller.prototype.uninstallSDK = originalLinuxUninstallSDK; - WinMacGlobalInstaller.prototype.getExpectedGlobalSDKPath = originalGetExpectedGlobalSDKPath; - WinMacGlobalInstaller.prototype.uninstallSDK = originalUninstallSDK; - if (originalDisableMutex === undefined) - { - delete process.env.VSCODE_DOTNET_RUNTIME_DISABLE_MUTEX; - } - else - { - process.env.VSCODE_DOTNET_RUNTIME_DISABLE_MUTEX = originalDisableMutex; - } + restorers.forEach(restore => restore()); } }).timeout(expectedTimeoutTime); From 297f125b0622ac92e96c6e890e6e25f5d09d7245 Mon Sep 17 00:00:00 2001 From: GaTT Geng Date: Tue, 23 Jun 2026 09:02:41 +0800 Subject: [PATCH 6/9] Improve cancellation message wording Co-authored-by: Noah Gilson --- vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts index 8735428e25..060ea929d0 100644 --- a/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts +++ b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts @@ -24,6 +24,6 @@ export function buildUninstallFailureMessage(version: string, result: string): s const asNumber = Number(normalized); const detail = normalized !== '' && Number.isInteger(asNumber) ? `code ${normalized}` : normalized; return isUserCancellationMessage(normalized) - ? `Uninstall of .NET ${version} was cancelled — the admin/elevation prompt was dismissed. Retry and accept the prompt to continue. (${detail})` + ? `Uninstall of .NET ${version} was cancelled. The elevation prompt was dismissed. Retry and accept the prompt to continue. (${detail})` : `Uninstall of .NET ${version} did not succeed (${detail}). The uninstaller may be blocked by another install in progress, or require manual removal.`; } From 912cdbce7287a1335bc716e664c7650aa13ae479 Mon Sep 17 00:00:00 2001 From: GaTT Geng Date: Tue, 23 Jun 2026 09:03:14 +0800 Subject: [PATCH 7/9] Remove unnecessary comma in uninstall failure message Co-authored-by: Noah Gilson --- vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts index 060ea929d0..4dbeb496e9 100644 --- a/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts +++ b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts @@ -25,5 +25,5 @@ export function buildUninstallFailureMessage(version: string, result: string): s const detail = normalized !== '' && Number.isInteger(asNumber) ? `code ${normalized}` : normalized; return isUserCancellationMessage(normalized) ? `Uninstall of .NET ${version} was cancelled. The elevation prompt was dismissed. Retry and accept the prompt to continue. (${detail})` - : `Uninstall of .NET ${version} did not succeed (${detail}). The uninstaller may be blocked by another install in progress, or require manual removal.`; + : `Uninstall of .NET ${version} did not succeed (${detail}). The uninstaller may be blocked by another install in progress or require manual removal.`; } From afe9e6f6ecf7fec0ce970ad2cc5f29bd8cff01c8 Mon Sep 17 00:00:00 2001 From: GaTT Geng Date: Tue, 23 Jun 2026 14:34:17 +0800 Subject: [PATCH 8/9] Deduplicate isUserCancellationMessage by importing from ErrorMessageUtilities --- .../src/ErrorMessageUtilities.ts | 2 +- .../src/LanguageModelTools.ts | 12 +----------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts index 4dbeb496e9..a906b8bf1a 100644 --- a/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts +++ b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts @@ -9,7 +9,7 @@ * 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. */ -function isUserCancellationMessage(message: string): boolean +export function isUserCancellationMessage(message: string): boolean { return /cancel|user rejected|user denied|password request|did not grant permission/i.test(message); } diff --git a/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts b/vscode-dotnet-runtime-extension/src/LanguageModelTools.ts index ffd94f02db..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,17 +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. - * Kept private to this module: it does not consider exit code 126, so callers outside the LM tool path should - * prefer the more robust check in CommandExecutor.parseVSCodeSudoExecError instead. - * "did not grant permission" comes from @vscode/sudo-prompt when the UAC dialog is dismissed on Windows. - */ -function isUserCancellationMessage(message: string): boolean -{ - return /cancel|user rejected|user denied|password request|did not grant permission/i.test(message); -} /** * Builds the minimal IAcquisitionWorkerContext that the stateless VersionUtilities parsing helpers require. From 3c80f3b239d84b1bd3e78a6153571a3c48a27e25 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 23 Jun 2026 16:53:37 -0700 Subject: [PATCH 9/9] Don't potentially include missing space . in error msg Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/ErrorMessageUtilities.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts index a906b8bf1a..aff3d4483a 100644 --- a/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts +++ b/vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts @@ -22,8 +22,9 @@ export function buildUninstallFailureMessage(version: string, result: string): s { const normalized = result.trim(); const asNumber = Number(normalized); - const detail = normalized !== '' && Number.isInteger(asNumber) ? `code ${normalized}` : normalized; + const detailText = normalized !== '' && Number.isInteger(asNumber) ? `code ${normalized}` : normalized; + const detailSuffix = detailText ? ` (${detailText})` : ''; return isUserCancellationMessage(normalized) - ? `Uninstall of .NET ${version} was cancelled. The elevation prompt was dismissed. Retry and accept the prompt to continue. (${detail})` - : `Uninstall of .NET ${version} did not succeed (${detail}). The uninstaller may be blocked by another install in progress or require manual removal.`; + ? `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.`; }