diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index 6ec2062f84..e11b23e20d 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -140,6 +140,9 @@ let extensionGlobalState: vscode.Memento | undefined; export function activate(vsCodeContext: vscode.ExtensionContext, extensionContext?: IExtensionContext) { + // All globalState keys are machine-specific (install paths, session tracking, etc.) + // and must not be synced to other machines or dev containers via Settings Sync. + vsCodeContext.globalState.setKeysForSync?.([]); if ((process.env.DOTNET_INSTALL_TOOL_UNDER_TEST === 'true' || (vsCodeContext?.extensionMode === vscode.ExtensionMode.Test)) && disableActivationUnderTest) { diff --git a/vscode-dotnet-runtime-extension/src/test/functional/DotnetCoreAcquisitionExtension.test.ts b/vscode-dotnet-runtime-extension/src/test/functional/DotnetCoreAcquisitionExtension.test.ts index b0794509b4..a9706f47e8 100644 --- a/vscode-dotnet-runtime-extension/src/test/functional/DotnetCoreAcquisitionExtension.test.ts +++ b/vscode-dotnet-runtime-extension/src/test/functional/DotnetCoreAcquisitionExtension.test.ts @@ -132,6 +132,13 @@ suite('DotnetCoreAcquisitionExtension End to End', function () assert.isAbove(extensionContext.subscriptions.length, 0); }).timeout(standardTimeoutTime); + test('GlobalState keys are not synced across machines', async () => + { + // setKeysForSync should have been called with an empty array during activation + // to prevent machine-specific install tracking state from leaking to dev containers + assert.deepEqual((mockState as any).syncedKeys, [], 'setKeysForSync should be called with empty array to prevent syncing install state'); + }).timeout(standardTimeoutTime); + async function installRuntime(dotnetVersion: string, installMode: DotnetInstallMode, arch?: string) { let context: IDotnetAcquireContext = { version: dotnetVersion, requestingExtensionId, mode: installMode }; diff --git a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts index cd41a21ac6..255baf2f3e 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts @@ -29,6 +29,7 @@ import { timeoutConstants } from '../Utils/ErrorHandler'; import { FileUtilities } from '../Utils/FileUtilities'; import { InstallScriptAcquisitionWorker } from './InstallScriptAcquisitionWorker'; +import { LocalMemoryCacheSingleton } from '../LocalMemoryCacheSingleton'; import { ICommandExecutor } from '../Utils/ICommandExecutor'; import { IUtilityContext } from '../Utils/IUtilityContext'; import { getDotnetExecutable } from '../Utils/TypescriptUtilities'; @@ -82,6 +83,7 @@ You will need to restart VS Code after these changes. If PowerShell is still not { if (await this.fileUtilities.exists(dotnetPath)) { + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining(installDir); const validator = new DotnetConditionValidator(this.workerContext, this.utilityContext); const meetsRequirement = await validator.dotnetMeetsRequirement(dotnetPath, { acquireContext: this.workerContext.acquisitionContext, versionSpecRequirement: 'equal' }); if (meetsRequirement) diff --git a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts index a9d359cb37..a7bc547cbe 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts @@ -52,6 +52,7 @@ import { IUtilityContext } from '../Utils/IUtilityContext'; import { executeWithLock, getDotnetExecutable, isRunningUnderWSL } from '../Utils/TypescriptUtilities'; import { DOTNET_INFORMATION_CACHE_DURATION_MS, GLOBAL_LOCK_PING_DURATION_MS, LOCAL_LOCK_PING_DURATION_MS } from './CacheTimeConstants'; import { directoryProviderFactory } from './DirectoryProviderFactory'; +import { LocalMemoryCacheSingleton } from '../LocalMemoryCacheSingleton'; import { DotnetConditionValidator } from './DotnetConditionValidator'; import { @@ -296,6 +297,7 @@ export class DotnetCoreAcquisitionWorker implements IDotnetCoreAcquisitionWorker throw reason; // This will get handled and cast into an event based error by its caller. }); + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining(dotnetInstallDir, context); context.installationValidator.validateDotnetInstall(install, dotnetPath); await this.removeMatchingLegacyInstall(context, installedVersions, version, true); await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).trackInstalledVersion(context, install, dotnetPath); @@ -476,10 +478,9 @@ ${interpretedMessage}`; dotnetExePath = await installer.getExpectedGlobalSDKPath(installingVersion, context.acquisitionContext.architecture ?? this.getDefaultInternalArchitecture(context.acquisitionContext.architecture)); + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining('dotnet', context); context.installationValidator.validateDotnetInstall(install, dotnetExePath, os.platform() === 'darwin', os.platform() !== 'darwin'); - context.eventStream.post(new DotnetAcquisitionCompleted(install, dotnetExePath, installingVersion)); - await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).trackInstalledVersion(context, install, dotnetExePath); await new CommandExecutor(context, this.utilityContext).endSudoProcessMaster(context.eventStream); @@ -573,6 +574,7 @@ ${interpretedMessage}`; { context.eventStream.post(new DotnetUninstallStarted(`Attempting to remove .NET ${install.installId}.`)); await this.file.wipeDirectory(dotnetInstallDir, context.eventStream, undefined, true,); + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining(dotnetInstallDir, context); await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).reportSuccessfulUninstall(context, install, force); context.eventStream.post(new DotnetUninstallCompleted(`Uninstalled .NET ${install.installId}.`)); } @@ -617,6 +619,7 @@ Other dependents remain.`)); 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') { diff --git a/vscode-dotnet-runtime-library/src/LocalMemoryCacheSingleton.ts b/vscode-dotnet-runtime-library/src/LocalMemoryCacheSingleton.ts index 1b73c0c618..0739f0cbed 100644 --- a/vscode-dotnet-runtime-library/src/LocalMemoryCacheSingleton.ts +++ b/vscode-dotnet-runtime-library/src/LocalMemoryCacheSingleton.ts @@ -127,6 +127,21 @@ export class LocalMemoryCacheSingleton this.cache.flushAll(); } + /** + * Invalidates all cache entries whose key contains the given substring. + * Used to evict stale results after an install or uninstall changes the state of a dotnet path. + */ + public invalidateEntriesContaining(substring: string, context?: IAcquisitionWorkerContext): void + { + const allKeys = this.cache.keys(); + const keysToDelete = allKeys.filter(k => k.includes(substring)); + if (keysToDelete.length > 0) + { + context?.eventStream.post(new CacheClearEvent(`Invalidating ${keysToDelete.length} cache entries containing '${substring}' at ${new Date().toISOString()}`)); + this.cache.del(keysToDelete); + } + } + private cacheableCommandToKey(key: CacheableCommand): string { // Get all keys sorted diff --git a/vscode-dotnet-runtime-library/src/test/mocks/MockObjects.ts b/vscode-dotnet-runtime-library/src/test/mocks/MockObjects.ts index 59367284ea..c656373435 100644 --- a/vscode-dotnet-runtime-library/src/test/mocks/MockObjects.ts +++ b/vscode-dotnet-runtime-library/src/test/mocks/MockObjects.ts @@ -44,6 +44,7 @@ const testDefaultTimeoutTimeMs = 60000; export class MockExtensionContext implements IExtensionState { private values: { [n: string]: any; } = {}; + public syncedKeys: readonly string[] | undefined = undefined; public get(key: string): T | undefined; public get(key: string, defaultValue: T): T; @@ -60,6 +61,10 @@ export class MockExtensionContext implements IExtensionState { return this.values[key] = value; } + public setKeysForSync(keys: readonly string[]): void + { + this.syncedKeys = keys; + } public clear() { this.values = {}; diff --git a/vscode-dotnet-runtime-library/src/test/unit/LocalMemoryCacheSingleton.test.ts b/vscode-dotnet-runtime-library/src/test/unit/LocalMemoryCacheSingleton.test.ts index db9295f29d..d2743bdce0 100644 --- a/vscode-dotnet-runtime-library/src/test/unit/LocalMemoryCacheSingleton.test.ts +++ b/vscode-dotnet-runtime-library/src/test/unit/LocalMemoryCacheSingleton.test.ts @@ -144,4 +144,102 @@ suite('LocalMemoryCacheSingleton Unit Tests', function () assert.deepEqual(LocalMemoryCacheSingleton.getInstance().getCommand(reorderedCommand, mockContext), outputValue, 'The reordered command should return the same cached value.'); assert.equal(LocalMemoryCacheSingleton.getInstance().getCommand(differentCommand, mockContext), undefined, 'A command with different options values should not be cached'); }); + + test('It invalidates only entries containing a specific substring', async () => + { + const installPath = '/home/user/.dotnet/10.0.5~x64~aspnetcore'; + const globalPath = '/usr/share/dotnet'; + + // Cache entries for the local install path + LocalMemoryCacheSingleton.getInstance().put( + `"${installPath}/dotnet" --list-runtimes --arch x64{"env":"..."}`, + { stdout: '', stderr: 'not found', status: '127' }, + { ttlMs: cacheTime }, + mockContext + ); + + // Cache entries for the global dotnet + LocalMemoryCacheSingleton.getInstance().put( + `"${globalPath}/dotnet" --list-runtimes --arch x64{"env":"..."}`, + { stdout: 'Microsoft.NETCore.App 10.0.4', stderr: '', status: '0' }, + { ttlMs: cacheTime }, + mockContext + ); + + // Invalidate only the local install path entries + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining(installPath, mockContext); + + // Local path entry should be gone + const localResult = LocalMemoryCacheSingleton.getInstance().get( + `"${installPath}/dotnet" --list-runtimes --arch x64{"env":"..."}`, + mockContext + ); + assert.isUndefined(localResult, 'The local install path cache entry should be invalidated'); + + // Global path entry should still exist + const globalResult = LocalMemoryCacheSingleton.getInstance().get( + `"${globalPath}/dotnet" --list-runtimes --arch x64{"env":"..."}`, + mockContext + ); + assert.isDefined(globalResult, 'The global dotnet cache entry should not be invalidated'); + }); + + test('It invalidates all dotnet entries for global install/uninstall', async () => + { + // Cache entries for multiple dotnet paths + LocalMemoryCacheSingleton.getInstance().put( + `"dotnet" --list-runtimes --arch x64{"env":"..."}`, + { stdout: 'Microsoft.NETCore.App 10.0.4', stderr: '', status: '0' }, + { ttlMs: cacheTime }, + mockContext + ); + + LocalMemoryCacheSingleton.getInstance().put( + `"/usr/share/dotnet/dotnet" --list-sdks --arch x64{"env":"..."}`, + { stdout: '10.0.200 [/usr/share/dotnet/sdk]', stderr: '', status: '0' }, + { ttlMs: cacheTime }, + mockContext + ); + + // Also cache a non-dotnet entry + LocalMemoryCacheSingleton.getInstance().put( + `which which{"env":"..."}`, + { stdout: '/usr/bin/which', stderr: '', status: '0' }, + { ttlMs: cacheTime }, + mockContext + ); + + // Invalidate with 'dotnet' substring (simulating global install/uninstall) + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining('dotnet', mockContext); + + // Both dotnet entries should be gone + assert.isUndefined( + LocalMemoryCacheSingleton.getInstance().get(`"dotnet" --list-runtimes --arch x64{"env":"..."}`, mockContext), + 'The bare dotnet cache entry should be invalidated' + ); + assert.isUndefined( + LocalMemoryCacheSingleton.getInstance().get(`"/usr/share/dotnet/dotnet" --list-sdks --arch x64{"env":"..."}`, mockContext), + 'The full-path dotnet cache entry should be invalidated' + ); + + // Non-dotnet entry should survive + assert.isDefined( + LocalMemoryCacheSingleton.getInstance().get(`which which{"env":"..."}`, mockContext), + 'The non-dotnet cache entry should not be invalidated' + ); + }); + + test('It does nothing when no entries match the substring', async () => + { + LocalMemoryCacheSingleton.getInstance().put('some-key', 'some-value', { ttlMs: cacheTime }, mockContext); + + // Invalidate with a substring that matches nothing + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining('/nonexistent/path', mockContext); + + assert.equal( + LocalMemoryCacheSingleton.getInstance().get('some-key', mockContext), + 'some-value', + 'Unrelated cache entries should not be affected' + ); + }); }); diff --git a/vscode-dotnet-sdk-extension/src/extension.ts b/vscode-dotnet-sdk-extension/src/extension.ts index 7c3d4f3442..453cb1b37e 100644 --- a/vscode-dotnet-sdk-extension/src/extension.ts +++ b/vscode-dotnet-sdk-extension/src/extension.ts @@ -70,6 +70,10 @@ const knownExtensionIds = ['ms-dotnettools.sample-extension', 'ms-dotnettools.vs export function activate(context: vscode.ExtensionContext, extensionContext?: IExtensionContext) { + // All globalState keys are machine-specific (install paths, session tracking, etc.) + // and must not be synced to other machines or dev containers via Settings Sync. + context.globalState.setKeysForSync?.([]); + const extensionConfiguration = extensionContext !== undefined && extensionContext.extensionConfiguration ? extensionContext.extensionConfiguration : vscode.workspace.getConfiguration(configPrefix);