diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index ec8ec502da..11ce3110d1 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -526,8 +526,10 @@ export function activate(vsCodeContext: vscode.ExtensionContext, extensionContex const dotnetUninstallPublicRegistration = vscode.commands.registerCommand(`${commandPrefix}.${commandKeys.uninstallPublic}`, async () => { - const existingInstalls: InstallRecord[] = await InstallTrackerSingleton.getInstance(globalEventStream, vsCodeContext.globalState).getExistingInstalls(directoryProviderFactory( - 'runtime', vsCodeContext.globalStoragePath)); + const dirProvider = directoryProviderFactory('runtime', vsCodeContext.globalStoragePath); + const tracker = InstallTrackerSingleton.getInstance(globalEventStream, vsCodeContext.globalState); + await tracker.pruneStaleInstalls(dirProvider); + const existingInstalls: InstallRecord[] = await tracker.getExistingInstalls(dirProvider); const menuItems = existingInstalls?.sort( function (x: InstallRecord, y: InstallRecord): number diff --git a/vscode-dotnet-runtime-library/src/Acquisition/InstallTrackerSingleton.ts b/vscode-dotnet-runtime-library/src/Acquisition/InstallTrackerSingleton.ts index 51812dbffb..9bfd824272 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/InstallTrackerSingleton.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/InstallTrackerSingleton.ts @@ -3,6 +3,7 @@ * The .NET Foundation licenses this file to you under the MIT license. *--------------------------------------------------------------------------------------------*/ import * as crypto from 'crypto'; +import * as fs from 'fs'; import * as path from 'path'; import { IEventStream } from '../EventStream/EventStream'; import @@ -22,7 +23,8 @@ import SearchingLiveDependents, SessionMutexAcquisitionFailed, SessionMutexReleased, - SkipAddingInstallEvent + SkipAddingInstallEvent, + StaleDotnetInstallRemovedEvent } from '../EventStream/EventStreamEvents'; import { IExtensionState } from '../IExtensionState'; import { EventStreamNodeIPCMutexLoggerWrapper } from '../Utils/EventStreamNodeIPCMutexWrapper'; @@ -409,6 +411,38 @@ export class InstallTrackerSingleton }, 'installed'); } + /** + * Validates tracked local installations against disk and removes any whose install directories + * no longer exist. This should be called before showing the list of installed runtimes to the user + * (e.g., in the uninstall UI) to ensure stale entries are cleaned up. + * Global installs are managed by the system and are not validated here. + * @param dirProvider The directory provider used to resolve each install's path on disk. + */ + public async pruneStaleInstalls(dirProvider: IInstallationDirectoryProvider): Promise + { + return executeWithLock(this.eventStream, false, this.getLockFilePathForKey(dirProvider, 'installed'), 5, 200000, + async (installState: InstallState) => + { + const existingInstalls = await this.getExistingInstalls(dirProvider, true); + const validInstalls = existingInstalls.filter(install => + { + if (install.dotnetInstall.isGlobal) + { + return true; + } + const installDir = dirProvider.getInstallDir(install.dotnetInstall.installId); + const existsOnDisk = fs.existsSync(installDir); + if (!existsOnDisk) + { + this.eventStream.post(new StaleDotnetInstallRemovedEvent( + `Removing stale install record for ${JSON.stringify(install.dotnetInstall)} because its directory ${installDir} no longer exists on disk.`)); + } + return existsOnDisk; + }); + await this.extensionState.update(installState, validInstalls); + }, 'installed'); + } + public async untrackInstalledVersion(context: IAcquisitionWorkerContext, install: DotnetInstall, force = false) { await this.removeVersionFromExtensionState(context, install, force); diff --git a/vscode-dotnet-runtime-library/src/EventStream/EventStreamEvents.ts b/vscode-dotnet-runtime-library/src/EventStream/EventStreamEvents.ts index 58662a2344..f70f1e4f3d 100644 --- a/vscode-dotnet-runtime-library/src/EventStream/EventStreamEvents.ts +++ b/vscode-dotnet-runtime-library/src/EventStream/EventStreamEvents.ts @@ -1502,6 +1502,11 @@ export class NoMatchingInstallToStopTracking extends DotnetCustomMessageEvent public readonly eventName = 'NoMatchingInstallToStopTracking'; } +export class StaleDotnetInstallRemovedEvent extends DotnetCustomMessageEvent +{ + public readonly eventName = 'StaleDotnetInstallRemovedEvent'; +} + export class CommandExecutionStdError extends DotnetCustomMessageEvent { public readonly eventName = 'CommandExecutionStdError'; diff --git a/vscode-dotnet-runtime-library/src/test/unit/InstallTracker.test.ts b/vscode-dotnet-runtime-library/src/test/unit/InstallTracker.test.ts index 3de623adbd..7f175c7179 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/InstallTracker.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/InstallTracker.test.ts @@ -7,6 +7,7 @@ import { ChildProcess, fork } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import { DotnetInstall } from '../../Acquisition/DotnetInstall'; +import { IInstallationDirectoryProvider } from '../../Acquisition/IInstallationDirectoryProvider'; import { InstallRecord } from '../../Acquisition/InstallRecord'; import { LocalMemoryCacheSingleton } from '../../LocalMemoryCacheSingleton'; import { getDotnetExecutable } from '../../Utils/TypescriptUtilities'; @@ -38,6 +39,25 @@ const secondInstall: DotnetInstall = { const defaultTimeoutTime = 5000; const eventStream = new MockEventStream(); const fakeValidDir = path.join(__dirname, 'dotnetFakeDir'); + +/** + * A directory provider that always returns a specific directory for local installs. + * Used by the stale install pruning test to control what path is checked on disk. + */ +class StubInstallationDirectoryProvider extends IInstallationDirectoryProvider +{ + constructor(private readonly validDir: string) + { + super(''); + } + + public getInstallDir(_installId: string): string + { + return this.validDir; + } +} + +const fakeDirectoryProvider = new StubInstallationDirectoryProvider(fakeValidDir); const mockContext = getMockAcquisitionContext(defaultMode, defaultVersion, defaultTimeoutTime, eventStream); const mockContextFromOtherExtension = getMockAcquisitionContext(defaultMode, defaultVersion, defaultTimeoutTime, eventStream); (mockContextFromOtherExtension.acquisitionContext)!.requestingExtensionId = 'testOther'; @@ -499,6 +519,48 @@ suite('InstallTracker Unit Tests', function () }).timeout(defaultTimeoutTime); + + test('It Removes Stale Local Installs Whose Directories No Longer Exist on Disk', async () => + { + resetExtensionState(); + + const staleInstallDir = path.join(os.tmpdir(), `dotnet-stale-${Date.now()}`); + const staleDirectoryProvider = new StubInstallationDirectoryProvider(staleInstallDir); + + const tracker = new MockInstallTracker(mockContext.eventStream, mockContext.extensionState); + + // Manually inject a tracked install record without creating the directory + await mockContext.extensionState.update('installed', [ + { + dotnetInstall: defaultInstall, + installingExtensions: ['test'] + } as InstallRecord + ]); + + // The install directory does not exist on disk + assert.isFalse(fs.existsSync(staleInstallDir), 'Stale install directory should not exist'); + + await tracker.pruneStaleInstalls(staleDirectoryProvider); + + const installsAfterPrune = await tracker.getExistingInstalls(staleDirectoryProvider); + assert.deepStrictEqual(installsAfterPrune, [], 'Stale install should be removed from tracked installs when its directory is missing'); + + // Verify the state was also updated to remove the stale entry + const stateAfter = mockContext.extensionState.get('installed', []); + assert.deepStrictEqual(stateAfter, [], 'Extension state should no longer contain the stale install'); + + // Ensure that valid (existing) installs are NOT removed + await mockContext.extensionState.update('installed', [ + { + dotnetInstall: defaultInstall, + installingExtensions: ['test'] + } as InstallRecord + ]); + + await tracker.pruneStaleInstalls(fakeDirectoryProvider); + const validInstalls = await tracker.getExistingInstalls(fakeDirectoryProvider); + assert.lengthOf(validInstalls, 1, 'Valid install whose directory exists should be retained'); + }).timeout(defaultTimeoutTime); }); suite('InstallTracker Session Mutex Tests', function ()