From 3a162660f508d13d699ded020ed353e323b290fd Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 31 Mar 2026 16:22:50 -0700 Subject: [PATCH 1/5] Do not sync install states across machines https://github.com/microsoft/vscode/blob/231f8d745c9e59b6a62ae33f230002d99216800e/src/vscode-dts/vscode.d.ts#L8454 Keys in the global state are synced when the user turns on configuration settings sync. I had no idea. This means we will sync file paths from different machines, even different OS's, across one another. All of our data keys are related to the specific machine so we should definitely not sync any of them. --- vscode-dotnet-runtime-extension/src/extension.ts | 3 +++ vscode-dotnet-sdk-extension/src/extension.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index 9b9a111527..c9f1e4bf77 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -139,6 +139,9 @@ let extensionEventStream: IEventStream | 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-sdk-extension/src/extension.ts b/vscode-dotnet-sdk-extension/src/extension.ts index 7c3d4f3442..69f47d9386 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); From ca0a5572197e9002106fddacd9785c4562a0482c Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 31 Mar 2026 16:35:48 -0700 Subject: [PATCH 2/5] Invalidate related entries upon install/uninstall --- .../src/Acquisition/AcquisitionInvoker.ts | 2 ++ .../Acquisition/DotnetCoreAcquisitionWorker.ts | 5 +++++ .../src/LocalMemoryCacheSingleton.ts | 15 +++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts index 86fc03d96f..a997587db7 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts @@ -32,6 +32,7 @@ import { InstallScriptAcquisitionWorker } from './InstallScriptAcquisitionWorker import { IUtilityContext } from '../Utils/IUtilityContext'; import { getDotnetExecutable } from '../Utils/TypescriptUtilities'; import { WebRequestWorkerSingleton } from '../Utils/WebRequestWorkerSingleton'; +import { LocalMemoryCacheSingleton } from '../LocalMemoryCacheSingleton'; import { DotnetConditionValidator } from './DotnetConditionValidator'; import { DotnetCoreAcquisitionWorker } from './DotnetCoreAcquisitionWorker'; import { DotnetInstall } from './DotnetInstall'; @@ -92,6 +93,7 @@ You will need to restart VS Code after these changes. If PowerShell is still not try { await this.fileUtilities.wipeDirectory(installDir, this.eventStream, undefined, true); + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining(installDir); } catch (err: any) { diff --git a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts index a9d359cb37..9099578ac3 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); @@ -478,6 +480,7 @@ ${interpretedMessage}`; context.installationValidator.validateDotnetInstall(install, dotnetExePath, os.platform() === 'darwin', os.platform() !== 'darwin'); + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining('dotnet', context); context.eventStream.post(new DotnetAcquisitionCompleted(install, dotnetExePath, installingVersion)); await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).trackInstalledVersion(context, install, dotnetExePath); @@ -573,6 +576,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}.`)); } @@ -620,6 +624,7 @@ Other dependents remain.`)); await new CommandExecutor(context, this.utilityContext).endSudoProcessMaster(context.eventStream); if (ok === '0') { + LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining('dotnet', context); await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).reportSuccessfulUninstall(context, install, force); context.eventStream.post(new DotnetUninstallCompleted(`Uninstalled .NET ${install.installId}.`)); return '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 From 64ffd11c1a4b07611ee6f9c6d50d89a7ddb64190 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 31 Mar 2026 16:50:36 -0700 Subject: [PATCH 3/5] move invalidation to better defense in depth location --- vscode-dotnet-runtime-extension/src/extension.ts | 2 +- .../src/Acquisition/AcquisitionInvoker.ts | 4 ++-- vscode-dotnet-sdk-extension/src/extension.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/vscode-dotnet-runtime-extension/src/extension.ts b/vscode-dotnet-runtime-extension/src/extension.ts index c9f1e4bf77..5c7cdcd3d5 100644 --- a/vscode-dotnet-runtime-extension/src/extension.ts +++ b/vscode-dotnet-runtime-extension/src/extension.ts @@ -141,7 +141,7 @@ export function activate(vsCodeContext: vscode.ExtensionContext, extensionContex { // 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([]); + vsCodeContext.globalState.setKeysForSync?.([]); if ((process.env.DOTNET_INSTALL_TOOL_UNDER_TEST === 'true' || (vsCodeContext?.extensionMode === vscode.ExtensionMode.Test)) && disableActivationUnderTest) { diff --git a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts index a997587db7..8efada7340 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/AcquisitionInvoker.ts @@ -29,10 +29,10 @@ import { timeoutConstants } from '../Utils/ErrorHandler'; import { FileUtilities } from '../Utils/FileUtilities'; import { InstallScriptAcquisitionWorker } from './InstallScriptAcquisitionWorker'; +import { LocalMemoryCacheSingleton } from '../LocalMemoryCacheSingleton'; import { IUtilityContext } from '../Utils/IUtilityContext'; import { getDotnetExecutable } from '../Utils/TypescriptUtilities'; import { WebRequestWorkerSingleton } from '../Utils/WebRequestWorkerSingleton'; -import { LocalMemoryCacheSingleton } from '../LocalMemoryCacheSingleton'; import { DotnetConditionValidator } from './DotnetConditionValidator'; import { DotnetCoreAcquisitionWorker } from './DotnetCoreAcquisitionWorker'; import { DotnetInstall } from './DotnetInstall'; @@ -81,6 +81,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) @@ -93,7 +94,6 @@ You will need to restart VS Code after these changes. If PowerShell is still not try { await this.fileUtilities.wipeDirectory(installDir, this.eventStream, undefined, true); - LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining(installDir); } catch (err: any) { diff --git a/vscode-dotnet-sdk-extension/src/extension.ts b/vscode-dotnet-sdk-extension/src/extension.ts index 69f47d9386..453cb1b37e 100644 --- a/vscode-dotnet-sdk-extension/src/extension.ts +++ b/vscode-dotnet-sdk-extension/src/extension.ts @@ -72,7 +72,7 @@ export function activate(context: vscode.ExtensionContext, extensionContext?: IE { // 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([]); + context.globalState.setKeysForSync?.([]); const extensionConfiguration = extensionContext !== undefined && extensionContext.extensionConfiguration ? extensionContext.extensionConfiguration : From 0206f5e689a2c8e3e4c7b1f147f3c945524f487c Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 8 Apr 2026 14:34:30 -0700 Subject: [PATCH 4/5] Add tests for ensuring the keys are not synced and cache invalidation works --- .../DotnetCoreAcquisitionExtension.test.ts | 7 ++ .../src/test/mocks/MockObjects.ts | 5 + .../unit/LocalMemoryCacheSingleton.test.ts | 98 +++++++++++++++++++ 3 files changed, 110 insertions(+) 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 ce1d66e495..b3c4233c04 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/test/mocks/MockObjects.ts b/vscode-dotnet-runtime-library/src/test/mocks/MockObjects.ts index 39d7f0175b..47b4a13bf1 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' + ); + }); }); From ca37d93b130e8fd8920da6e81e13e52abbf12d0f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Wed, 8 Apr 2026 14:55:53 -0700 Subject: [PATCH 5/5] fix some of the invalidations to happen before we call validate validate itself may be wrong if we dont wipe the cache before we check the state post install / uninstall --- .../src/Acquisition/DotnetCoreAcquisitionWorker.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts index 9099578ac3..a7bc547cbe 100644 --- a/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts +++ b/vscode-dotnet-runtime-library/src/Acquisition/DotnetCoreAcquisitionWorker.ts @@ -478,11 +478,9 @@ ${interpretedMessage}`; dotnetExePath = await installer.getExpectedGlobalSDKPath(installingVersion, context.acquisitionContext.architecture ?? this.getDefaultInternalArchitecture(context.acquisitionContext.architecture)); - context.installationValidator.validateDotnetInstall(install, dotnetExePath, os.platform() === 'darwin', os.platform() !== 'darwin'); - 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); @@ -621,10 +619,10 @@ 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') { - LocalMemoryCacheSingleton.getInstance().invalidateEntriesContaining('dotnet', context); await InstallTrackerSingleton.getInstance(context.eventStream, context.extensionState).reportSuccessfulUninstall(context, install, force); context.eventStream.post(new DotnetUninstallCompleted(`Uninstalled .NET ${install.installId}.`)); return '0';