Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions vscode-dotnet-runtime-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +23,8 @@ import
SearchingLiveDependents,
SessionMutexAcquisitionFailed,
SessionMutexReleased,
SkipAddingInstallEvent
SkipAddingInstallEvent,
StaleDotnetInstallRemovedEvent
} from '../EventStream/EventStreamEvents';
import { IExtensionState } from '../IExtensionState';
import { EventStreamNodeIPCMutexLoggerWrapper } from '../Utils/EventStreamNodeIPCMutexWrapper';
Expand Down Expand Up @@ -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<void>
{
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
62 changes: 62 additions & 0 deletions vscode-dotnet-runtime-library/src/test/unit/InstallTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<InstallRecord[]>('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 ()
Expand Down