Skip to content
30 changes: 30 additions & 0 deletions vscode-dotnet-runtime-extension/src/ErrorMessageUtilities.ts
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +7 to +10
*/
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.`;
}
9 changes: 1 addition & 8 deletions vscode-dotnet-runtime-extension/src/LanguageModelTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion vscode-dotnet-runtime-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I like the use of buildUninstallFailureMessage here. I think outside of registering the language model tools, I'd rather avoid importing any implementation from that file here because it will increase the risk of circular dependencies.

I suggest we move buildUninstallFailureMessage into a separate file now, such as ErrorMessageUtilities.ts like how there is a VersionUtilities.ts file.

I would do similarly for isUserCancellationMessage or ideally not export it at all. That method doesn't consider exit code 126 and I'd rather it not be used elsewhere as it may be confused with the check in parseVSCodeSudoExecError which is more robust.

}
}
}, getIssueContext(existingPathConfigWorker)(commandContext?.errorConfiguration, 'uninstall'), commandContext?.requestingExtensionId, workerContext, commandContext?.rethrowError);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '')))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice little optimization here by changing the tautology.

{
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';
Comment on lines +651 to +655
}
catch (error: any)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -22,6 +26,7 @@ import
DotnetLockEvent,
DotnetUninstallAllCompleted,
DotnetUninstallAllStarted,
DotnetUninstallFailed,
TestAcquireCalled
} from '../../EventStream/EventStreamEvents';
import { EventType } from '../../EventStream/EventType';
Expand Down Expand Up @@ -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';
Expand Down