Skip to content
Open
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
3 changes: 3 additions & 0 deletions vscode-dotnet-runtime-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ 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';
Expand Down Expand Up @@ -80,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}.`));
}
Expand Down Expand Up @@ -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')
{
Expand Down
15 changes: 15 additions & 0 deletions vscode-dotnet-runtime-library/src/LocalMemoryCacheSingleton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions vscode-dotnet-runtime-library/src/test/mocks/MockObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(key: string): T | undefined;
public get<T>(key: string, defaultValue: T): T;
Expand All @@ -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 = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
});
4 changes: 4 additions & 0 deletions vscode-dotnet-sdk-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down