Skip to content

Commit

Permalink
Add API to uninstall just one runtime (#1894)
Browse files Browse the repository at this point in the history
* Add API to uninstall any install

* Add tests

* undo bad save

* dont uninstall if there are multiple owners
  • Loading branch information
nagilson authored Jul 30, 2024
1 parent a9cbf6b commit a92e3c8
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 24 deletions.
49 changes: 49 additions & 0 deletions vscode-dotnet-runtime-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
UserManualInstallVersionChosen,
UserManualInstallRequested,
UserManualInstallSuccess,
InvalidUninstallRequest,
UserManualInstallFailure,
DotnetInstall,
EventCancellationError,
Expand All @@ -78,6 +79,7 @@ namespace commandKeys {
export const acquire = 'acquire';
export const acquireGlobalSDK = 'acquireGlobalSDK';
export const acquireStatus = 'acquireStatus';
export const uninstall = 'uninstall';
export const uninstallAll = 'uninstallAll';
export const listVersions = 'listVersions';
export const recommendedVersion = 'recommendedVersion'
Expand Down Expand Up @@ -336,6 +338,52 @@ export function activate(vsCodeContext: vscode.ExtensionContext, extensionContex
return pathResult;
});

/**
* @returns 0 on success. Error string if not.
*/
const dotnetUninstallRegistration = vscode.commands.registerCommand(`${commandPrefix}.${commandKeys.uninstall}`, async (commandContext: IDotnetAcquireContext | undefined) =>
{
return uninstall(commandContext);
});

async function uninstall(commandContext: IDotnetAcquireContext | undefined) : Promise<string>
{
let result = '1';
await callWithErrorHandling(async () =>
{
if(!commandContext?.version || !commandContext?.installType || !commandContext?.mode || !commandContext?.requestingExtensionId)
{
const error = new EventCancellationError('InvalidUninstallRequest', `The caller ${commandContext?.requestingExtensionId} did not properly submit an uninstall request.
Please include the mode, installType, version, and extensionId.`);
globalEventStream.post(new InvalidUninstallRequest(error as Error));
throw error;
}
else
{
const worker = getAcquisitionWorker();
const workerContext = getAcquisitionWorkerContext(commandContext.mode, commandContext);
const versionResolver = new VersionResolver(workerContext);
const resolvedVersion = await versionResolver.getFullVersion(commandContext.version, commandContext.mode);
commandContext.version = resolvedVersion;

const installationId = getInstallIdCustomArchitecture(commandContext.version, commandContext.architecture, commandContext.mode, commandContext.installType);
const install = {installId : installationId, version : commandContext.version, installMode: commandContext.mode, isGlobal: commandContext.installType === 'global',
architecture: commandContext.architecture ?? DotnetCoreAcquisitionWorker.defaultArchitecture()} as DotnetInstall;

if(commandContext.installType === 'local')
{
result = await worker.uninstallLocalRuntimeOrSDK(workerContext, install);
}
else
{
result = await worker.uninstallGlobal(workerContext, install);
}
}
}, getIssueContext(existingPathConfigWorker)(commandContext?.errorConfiguration, 'uninstall'));

return result;
}

const dotnetUninstallAllRegistration = vscode.commands.registerCommand(`${commandPrefix}.${commandKeys.uninstallAll}`, async (commandContext: IDotnetUninstallContext | undefined) => {
await callWithErrorHandling(async () =>
{
Expand Down Expand Up @@ -506,6 +554,7 @@ export function activate(vsCodeContext: vscode.ExtensionContext, extensionContex
acquireGlobalSDKPublicRegistration,
dotnetListVersionsRegistration,
dotnetRecommendedVersionRegistration,
dotnetUninstallRegistration,
dotnetUninstallAllRegistration,
showOutputChannelRegistration,
ensureDependenciesRegistration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import {
MockWebRequestWorker,
MockWindowDisplayWorker,
getMockAcquisitionContext,
DotnetInstallMode
DotnetInstallMode,
DotnetInstallType
} from 'vscode-dotnet-runtime-library';
import * as extension from '../../extension';
import { warn } from 'console';
Expand Down Expand Up @@ -125,6 +126,22 @@ suite('DotnetCoreAcquisitionExtension End to End', function()
await installRuntime('2.2', 'aspnetcore');
}).timeout(standardTimeoutTime);

async function installUninstallOne(dotnetVersion : string, versionToKeep : string, installMode : DotnetInstallMode, type : DotnetInstallType)
{
const context: IDotnetAcquireContext = { version: dotnetVersion, requestingExtensionId, mode: installMode, installType: type };
const contextToKeep: IDotnetAcquireContext = { version: versionToKeep, requestingExtensionId, mode: installMode, installType: type };

const result = await vscode.commands.executeCommand<IDotnetAcquireResult>('dotnet.acquire', context);
const resultToKeep = await vscode.commands.executeCommand<IDotnetAcquireResult>('dotnet.acquire', contextToKeep);
assert.exists(result?.dotnetPath, 'The install succeeds and returns a path');
assert.exists(resultToKeep?.dotnetPath, 'The 2nd install succeeds and returns a path');

const uninstallResult = await vscode.commands.executeCommand<string>('dotnet.uninstall', context);
assert.equal(uninstallResult, '0', 'Uninstall returns 0');
assert.isFalse(fs.existsSync(result!.dotnetPath), 'the dotnet path result does not exist after uninstall');
assert.isTrue(fs.existsSync(resultToKeep!.dotnetPath), 'Only one thing is uninstalled.');
}

async function installUninstallAll(dotnetVersion : string, installMode : DotnetInstallMode)
{
const context: IDotnetAcquireContext = { version: dotnetVersion, requestingExtensionId, mode: installMode };
Expand All @@ -137,14 +154,49 @@ suite('DotnetCoreAcquisitionExtension End to End', function()
assert.isFalse(fs.existsSync(result!.dotnetPath), 'the dotnet path result does not exist after uninstall');
}

test('Uninstall Local Runtime Command', async () => {
async function uninstallWithMultipleOwners(dotnetVersion : string, installMode : DotnetInstallMode, type : DotnetInstallType)
{
const context: IDotnetAcquireContext = { version: dotnetVersion, requestingExtensionId, mode: installMode, installType: type };
const contextFromOtherId: IDotnetAcquireContext = { version: dotnetVersion, requestingExtensionId: 'fake.extension.two', mode: installMode, installType: type };

const result = await vscode.commands.executeCommand<IDotnetAcquireResult>('dotnet.acquire', context);
const resultToKeep = await vscode.commands.executeCommand<IDotnetAcquireResult>('dotnet.acquire', contextFromOtherId);
assert.exists(result?.dotnetPath, 'The install succeeds and returns a path');
assert.equal(result?.dotnetPath, resultToKeep?.dotnetPath, 'The two dupe installs use the same path');

const uninstallResult = await vscode.commands.executeCommand<string>('dotnet.uninstall', context);
assert.equal(uninstallResult, '0', '1st owner Uninstall returns 0');
assert.isTrue(fs.existsSync(resultToKeep!.dotnetPath), 'Nothing is uninstalled without FORCE if theres multiple owners.');

const finalUninstallResult = await vscode.commands.executeCommand<string>('dotnet.uninstall', contextFromOtherId);
assert.equal(finalUninstallResult, '0', '2nd owner Uninstall returns 0');
assert.isFalse(fs.existsSync(result!.dotnetPath), 'the dotnet path result does not exist after uninstalling from all owners');
}

test('Uninstall One Local Runtime Command', async () => {
await installUninstallOne('2.2', '8.0', 'runtime', 'local');
}).timeout(standardTimeoutTime);

test('Uninstall One Local ASP.NET Runtime Command', async () => {
await installUninstallOne('2.2', '8.0', 'aspnetcore', 'local');
}).timeout(standardTimeoutTime);

test('Uninstall All Local Runtime Command', async () => {
await installUninstallAll('2.2', 'runtime')
}).timeout(standardTimeoutTime);

test('Uninstall Local ASP.NET Runtime Command', async () => {
test('Uninstall All Local ASP.NET Runtime Command', async () => {
await installUninstallAll('2.2', 'aspnetcore')
}).timeout(standardTimeoutTime);

test('Uninstall Runtime Only Once No Owners Exist', async () => {
await uninstallWithMultipleOwners('8.0', 'runtime', 'local');
}).timeout(standardTimeoutTime);

test('Uninstall ASP.NET Runtime Only Once No Owners Exist', async () => {
await uninstallWithMultipleOwners('8.0', 'aspnetcore', 'local');
}).timeout(standardTimeoutTime);

async function installMultipleVersions(versions : string[], installMode : DotnetInstallMode)
{
let dotnetPaths: string[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -499,11 +499,11 @@ ${WinMacGlobalInstaller.InterpretExitCode(installerResult)}`), install);
}


public async uninstallLocalRuntimeOrSDK(context: IAcquisitionWorkerContext, install : DotnetInstall)
public async uninstallLocalRuntimeOrSDK(context: IAcquisitionWorkerContext, install : DotnetInstall, force = false) : Promise<string>
{
if(install.isGlobal)
{
return;
return '0';
}

try
Expand All @@ -514,21 +514,36 @@ ${WinMacGlobalInstaller.InterpretExitCode(installerResult)}`), install);
graveyard.add(install, dotnetInstallDir);
context.eventStream.post(new DotnetInstallGraveyardEvent(`Attempting to remove .NET at ${install} in path ${dotnetInstallDir}`));

this.removeFolderRecursively(context.eventStream, dotnetInstallDir);

await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).untrackInstalledVersion(context, install);
await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).untrackInstalledVersion(context, install, force);
// this is the only place where installed and installing could deal with pre existing installing id
await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).untrackInstallingVersion(context, install);
await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).untrackInstallingVersion(context, install, force);

graveyard.remove(install);
context.eventStream.post(new DotnetInstallGraveyardEvent(`Success at uninstalling ${JSON.stringify(install)} in path ${dotnetInstallDir}`));
if(force || await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).canUninstall(true, install))
{
this.removeFolderRecursively(context.eventStream, dotnetInstallDir);
graveyard.remove(install);
context.eventStream.post(new DotnetInstallGraveyardEvent(`Success at uninstalling ${JSON.stringify(install)} in path ${dotnetInstallDir}`));
}
else
{
context.eventStream.post(new DotnetInstallGraveyardEvent(`Removed reference of ${JSON.stringify(install)} in path ${dotnetInstallDir}, but did not uninstall.
Other dependents remain.`));
}

return '0';
}
catch(error : any)
{
context.eventStream.post(new SuppressedAcquisitionError(error, `The attempt to uninstall .NET ${install} failed - was .NET in use?`))
context.eventStream.post(new SuppressedAcquisitionError(error, `The attempt to uninstall .NET ${install} failed - was .NET in use?`));
return error?.message ?? '1';
}
}

public async uninstallGlobal(context: IAcquisitionWorkerContext, install : DotnetInstall, force = false) : Promise<string>
{
// Do nothing right now. Add this in another PR.
return '1';
}

private removeFolderRecursively(eventStream: IEventStream, folderPath: string) {
eventStream.post(new DotnetAcquisitionDeletion(folderPath));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,18 @@ Installs: ${[...this.inProgressInstalls].map(x => x.dotnetInstall.installId).joi
}, installId);
}

public async canUninstall(isFinishedInstall : boolean, dotnetInstall : DotnetInstall) : Promise<boolean>
{
return this.executeWithLock( false, async (id: string, install: DotnetInstall) =>
{
this.eventStream.post(new RemovingVersionFromExtensionState(`Removing ${JSON.stringify(install)} with id ${id} from the state.`));
const existingInstalls = await this.getExistingInstalls(id === this.installedVersionsId, true);
const installRecord = existingInstalls.filter(x => IsEquivalentInstallation(x.dotnetInstall, install));

return installRecord.length === 0 || installRecord[0].installingExtensions.length === 0;
}, isFinishedInstall ? this.installedVersionsId : this.installingVersionsId, dotnetInstall);
}

public async uninstallAllRecords() : Promise<void>
{
return this.executeWithLock( false, async () =>
Expand Down Expand Up @@ -244,18 +256,18 @@ ${convertedInstalls.map(x => `${JSON.stringify(x.dotnetInstall)} owned by ${x.in
await this.trackInstalledVersion(context, install);
}

public async untrackInstallingVersion(context : IAcquisitionWorkerContext, install : DotnetInstall)
public async untrackInstallingVersion(context : IAcquisitionWorkerContext, install : DotnetInstall, force = false)
{
await this.removeVersionFromExtensionState(context, this.installingVersionsId, install);
await this.removeVersionFromExtensionState(context, this.installingVersionsId, install, force);
this.removePromise(install);
}

public async untrackInstalledVersion(context : IAcquisitionWorkerContext, install : DotnetInstall)
public async untrackInstalledVersion(context : IAcquisitionWorkerContext, install : DotnetInstall, force = false)
{
await this.removeVersionFromExtensionState(context, this.installedVersionsId, install);
await this.removeVersionFromExtensionState(context, this.installedVersionsId, install, force);
}

protected async removeVersionFromExtensionState(context : IAcquisitionWorkerContext, idStr: string, installIdObj: DotnetInstall)
protected async removeVersionFromExtensionState(context : IAcquisitionWorkerContext, idStr: string, installIdObj: DotnetInstall, forceUninstall = false)
{
return this.executeWithLock( false, async (id: string, install: DotnetInstall) =>
{
Expand All @@ -275,12 +287,13 @@ ${convertedInstalls.map(x => `${JSON.stringify(x.dotnetInstall)} owned by ${x.in

const preExistingRecord = installRecord.at(0);
const owners = preExistingRecord?.installingExtensions.filter(x => x !== context.acquisitionContext?.requestingExtensionId);
if((owners?.length ?? 0) < 1)
if(forceUninstall || (owners?.length ?? 0) < 1)
{
// There are no more references/extensions that depend on this install, so remove the install from the list entirely.
// For installing versions, there should only ever be 1 owner.
// For installed versions, there can be N owners.
this.eventStream.post(new RemovingExtensionFromList(`The last owner ${context.acquisitionContext?.requestingExtensionId} removed ${JSON.stringify(install)} entirely from the state.`));
this.eventStream.post(new RemovingExtensionFromList(forceUninstall ? `At the request of ${context.acquisitionContext?.requestingExtensionId}, we force uninstalled ${JSON.stringify(install)}.` :
`The last owner ${context.acquisitionContext?.requestingExtensionId} removed ${JSON.stringify(install)} entirely from the state.`));
await this.extensionState.update(id, existingInstalls.filter(x => !IsEquivalentInstallation(x.dotnetInstall, install)));
}
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,10 @@ export class DotnetNotInstallRelatedCommandFailed extends DotnetNonAcquisitionEr
}
}

export class InvalidUninstallRequest extends DotnetNonAcquisitionError {
public readonly eventName = 'InvalidUninstallRequest';
}

export class DotnetCommandFailed extends DotnetAcquisitionError {
public readonly eventName = 'DotnetCommandFailed';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ suite('DotnetCoreAcquisitionWorker Unit Tests', function () {
assert.equal(events.length, 1);
}

async function acquireAndUninstall(version : string, mode : DotnetInstallMode, type : DotnetInstallType)
async function acquireAndUninstallAll(version : string, mode : DotnetInstallMode, type : DotnetInstallType)
{
const [eventStream, extContext] = setupStates();
const ctx = getMockAcquisitionContext(mode, version, expectedTimeoutTime, eventStream, extContext);
Expand Down Expand Up @@ -281,17 +281,17 @@ suite('DotnetCoreAcquisitionWorker Unit Tests', function () {

test('Acquire Runtime and UninstallAll', async () =>
{
await acquireAndUninstall('1.0', 'runtime', 'local');
await acquireAndUninstallAll('1.0', 'runtime', 'local');
}).timeout(expectedTimeoutTime);

test('Acquire ASP.NET and UninstallAll', async () =>
{
await acquireAndUninstall('1.0', 'aspnetcore', 'local');
await acquireAndUninstallAll('1.0', 'aspnetcore', 'local');
}).timeout(expectedTimeoutTime);

test('Acquire SDK and UninstallAll', async () =>
{
await acquireAndUninstall('6.0', 'sdk', 'local');
await acquireAndUninstallAll('6.0', 'sdk', 'local');
}).timeout(expectedTimeoutTime);

test('Graveyard Removes Failed Uninstalls', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,11 @@ suite('DotnetCoreAcquisitionExtension End to End', function()
assert.exists(result, 'basic install works');
assert.exists(result!.dotnetPath, 'basic install has path');
let sdkDirs = fs.readdirSync(path.join(path.dirname(result!.dotnetPath), 'sdk'));
assert.isNotEmpty(sdkDirs.filter(dir => dir.includes(version)), 'sdk directories include version');
assert.isNotEmpty(sdkDirs.filter(dir => dir.includes(version)), `sdk directories include version?
PATH: ${result!.dotnetPath}
PATH SUBDIRECTORIES: ${fs.readdirSync(path.dirname(result!.dotnetPath))}
SDK SUBDIRECTORIES: ${sdkDirs}
VERSION: ${version}`);

// Install 5.0
version = '5.0';
Expand Down

0 comments on commit a92e3c8

Please sign in to comment.