Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { DotnetConditionsValidated, DotnetFindPathDidNotMeetCondition, DotnetUnableToCheckPATHArchitecture } from '../EventStream/EventStreamEvents';
import { IDotnetFindPathContext } from '../IDotnetFindPathContext';
import { CommandExecutor } from '../Utils/CommandExecutor';
import { CommandExecutorCommand } from '../Utils/CommandExecutorCommand';
import { FileUtilities } from '../Utils/FileUtilities';
import { ICommandExecutor } from '../Utils/ICommandExecutor';
import { IUtilityContext } from '../Utils/IUtilityContext';
Expand All @@ -29,41 +30,59 @@ export class DotnetConditionValidator implements IDotnetConditionValidator
public async dotnetMeetsRequirement(dotnetExecutablePath: string, requirement: IDotnetFindPathContext): Promise<boolean>
{
let hostArch = '';
const oldLookup = process.env.DOTNET_MULTILEVEL_LOOKUP;
// This is deprecated but still needed to scan .NET 6 and below
process.env.DOTNET_MULTILEVEL_LOOKUP = '0'; // make it so --list-runtimes only finds the runtimes on that path: https://learn.microsoft.com/en-us/dotnet/core/compatibility/deployment/7.0/multilevel-lookup#reason-for-change

if (requirement.acquireContext.mode === 'sdk')
try
{
const availableSDKs = await this.getSDKs(dotnetExecutablePath, requirement.acquireContext.architecture ?? DotnetCoreAcquisitionWorker.defaultArchitecture());
hostArch = availableSDKs?.at(0)?.architecture ?? await this.getHostArchitecture(dotnetExecutablePath, requirement);
if (availableSDKs.some((sdk) =>
if (requirement.acquireContext.mode === 'sdk')
{
return this.stringArchitectureMeetsRequirement(hostArch, requirement.acquireContext.architecture) &&
this.stringVersionMeetsRequirement(sdk.version, requirement.acquireContext.version, requirement) && this.allowPreview(sdk.version, requirement);
}))
const availableSDKs = await this.getSDKs(dotnetExecutablePath, requirement.acquireContext.architecture ?? DotnetCoreAcquisitionWorker.defaultArchitecture());
hostArch = availableSDKs?.at(0)?.architecture ?? await this.getHostArchitecture(dotnetExecutablePath, requirement);
if (availableSDKs.some((sdk) =>
{
return this.stringArchitectureMeetsRequirement(hostArch, requirement.acquireContext.architecture) &&
this.stringVersionMeetsRequirement(sdk.version, requirement.acquireContext.version, requirement) && this.allowPreview(sdk.version, requirement);
}))
{
this.workerContext.eventStream.post(new DotnetConditionsValidated(`${dotnetExecutablePath} satisfies the conditions.`));
return true;
}
}
else
{
this.workerContext.eventStream.post(new DotnetConditionsValidated(`${dotnetExecutablePath} satisfies the conditions.`));
return true;
// No need to consider SDKs when looking for runtimes as all the runtimes installed with the SDKs will be included in the runtimes list.
const availableRuntimes = await this.getRuntimes(dotnetExecutablePath, requirement.acquireContext.architecture ?? DotnetCoreAcquisitionWorker.defaultArchitecture());
hostArch = availableRuntimes?.at(0)?.architecture ?? await this.getHostArchitecture(dotnetExecutablePath, requirement);
if (availableRuntimes.some((runtime) =>
{
return runtime.mode === requirement.acquireContext.mode && this.stringArchitectureMeetsRequirement(hostArch, requirement.acquireContext.architecture) &&
this.stringVersionMeetsRequirement(runtime.version, requirement.acquireContext.version, requirement) && this.allowPreview(runtime.version, requirement);
}))
{
this.workerContext.eventStream.post(new DotnetConditionsValidated(`${dotnetExecutablePath} satisfies the conditions.`));
return true;
}
}

this.workerContext.eventStream.post(new DotnetFindPathDidNotMeetCondition(`${dotnetExecutablePath} did NOT satisfy the conditions: hostArch: ${hostArch}, requiredArch: ${requirement.acquireContext.architecture},
required version: ${requirement.acquireContext.version}, required mode: ${requirement.acquireContext.mode}`));

return false;
}
else
finally
{
// No need to consider SDKs when looking for runtimes as all the runtimes installed with the SDKs will be included in the runtimes list.
const availableRuntimes = await this.getRuntimes(dotnetExecutablePath, requirement.acquireContext.architecture ?? DotnetCoreAcquisitionWorker.defaultArchitecture());
hostArch = availableRuntimes?.at(0)?.architecture ?? await this.getHostArchitecture(dotnetExecutablePath, requirement);
if (availableRuntimes.some((runtime) =>
// Restore the environment variable to its original value
if (oldLookup !== undefined)
{
return runtime.mode === requirement.acquireContext.mode && this.stringArchitectureMeetsRequirement(hostArch, requirement.acquireContext.architecture) &&
this.stringVersionMeetsRequirement(runtime.version, requirement.acquireContext.version, requirement) && this.allowPreview(runtime.version, requirement);
}))
process.env.DOTNET_MULTILEVEL_LOOKUP = oldLookup;
}
else
{
this.workerContext.eventStream.post(new DotnetConditionsValidated(`${dotnetExecutablePath} satisfies the conditions.`));
return true;
delete process.env.DOTNET_MULTILEVEL_LOOKUP;
}
}

this.workerContext.eventStream.post(new DotnetFindPathDidNotMeetCondition(`${dotnetExecutablePath} did NOT satisfy the conditions: hostArch: ${hostArch}, requiredArch: ${requirement.acquireContext.architecture},
required version: ${requirement.acquireContext.version}, required mode: ${requirement.acquireContext.mode}`));

return false;
}

/**
Expand Down Expand Up @@ -303,6 +322,11 @@ Please set the PATH to a dotnet host that matches the architecture ${requirement
return true;
}

private getRuntimesCommand(existingPath: string, requestedArchitecture: string): CommandExecutorCommand
{
return CommandExecutor.makeCommand(`"${existingPath}"`, ['--list-runtimes', '--arch', requestedArchitecture]);
}

public async getRuntimes(existingPath: string, requestedArchitecture: string | null): Promise<IDotnetListInfo[]>
{
if (!existingPath || existingPath === '""')
Expand All @@ -311,13 +335,12 @@ Please set the PATH to a dotnet host that matches the architecture ${requirement
}

requestedArchitecture ??= DotnetCoreAcquisitionWorker.defaultArchitecture()
const findRuntimesCommand = CommandExecutor.makeCommand(`"${existingPath}"`, ['--list-runtimes', '--arch', requestedArchitecture]);

const windowsDesktopString = 'Microsoft.WindowsDesktop.App';
const aspnetCoreString = 'Microsoft.AspNetCore.App';
const runtimeString = 'Microsoft.NETCore.App';

const runtimeInfo = await (this.executor!).execute(findRuntimesCommand, { dotnetInstallToolCacheTtlMs: DOTNET_INFORMATION_CACHE_DURATION_MS }, false).then(async (result) =>
const runtimeInfo = await (this.executor!).execute(this.getRuntimesCommand(existingPath, requestedArchitecture), { dotnetInstallToolCacheTtlMs: DOTNET_INFORMATION_CACHE_DURATION_MS }, false).then(async (result) =>
{
if (result.status !== '0')
{
Expand Down
20 changes: 18 additions & 2 deletions vscode-dotnet-runtime-library/src/Acquisition/DotnetPathFinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ import
DotnetFindPathRootUnderEmulationButNoneSet,
FileDoesNotExist
} from '../EventStream/EventStreamEvents';
import { LocalMemoryCacheSingleton } from '../LocalMemoryCacheSingleton';
import { FileUtilities } from '../Utils/FileUtilities';
import { IFileUtilities } from '../Utils/IFileUtilities';
import { EnvironmentVariableIsDefined, getDotnetExecutable, getOSArch, getPathSeparator } from '../Utils/TypescriptUtilities';
import { DOTNET_INFORMATION_CACHE_DURATION_MS, SYS_CMD_SEARCH_CACHE_DURATION_MS } from './CacheTimeConstants';
import { DotnetConditionValidator } from './DotnetConditionValidator';
import { DotnetCoreAcquisitionWorker } from './DotnetCoreAcquisitionWorker';
import { InstallRecordWithPath } from './InstallRecordWithPath';
import { InstallTrackerSingleton } from './InstallTrackerSingleton';
import { RegistryReader } from './RegistryReader';
Expand Down Expand Up @@ -380,11 +382,13 @@ export class DotnetPathFinder implements IDotnetPathFinder
public async getTruePath(tentativePaths: string[], requestedArchitecture: string | null): Promise<string[]>
{
const truePaths = [];
requestedArchitecture ??= DotnetCoreAcquisitionWorker.defaultArchitecture()

for (const tentativePath of tentativePaths)
{
// This will even work if only the sdk is installed, list-runtimes on an sdk installed host would work
const runtimeInfo = await new DotnetConditionValidator(this.workerContext, this.utilityContext, this.executor).getRuntimes(tentativePath, requestedArchitecture);
const validator = new DotnetConditionValidator(this.workerContext, this.utilityContext, this.executor);
const runtimeInfo = await validator.getRuntimes(tentativePath, requestedArchitecture);
if ((runtimeInfo?.length ?? 0) > 0)
{
// q.t. from @dibarbet on the C# Extension:
Expand All @@ -396,7 +400,19 @@ export class DotnetPathFinder implements IDotnetPathFinder
//
// Since dotnet --list-runtimes will always use the real assembly path to output the runtime folder (no symlinks!)
// we know the dotnet executable will be two folders up in the install root.
truePaths.push(path.join(path.dirname(path.dirname(runtimeInfo[0].directory)), getDotnetExecutable()));
const truePath = path.join(path.dirname(path.dirname(runtimeInfo[0].directory)), getDotnetExecutable());
truePaths.push(truePath);

// Preload the cache with the calls we've already done.
// Example: 'dotnet' --list-runtimes will be the same as 'C:\\Program Files\\dotnet\\dotnet.exe' --list-runtimes
// If the dotnet executable full path was 'C:\\Program Files\\dotnet\\dotnet.exe'.

// We do NOT want to do this on Unix, because the dotnet executable is potentially polymorphic.
// /usr/local/bin/dotnet becomes /snap/dotnet-sdk/current/dotnet in reality, may have different behavior in shells.
if (os.platform() === 'win32')
{
LocalMemoryCacheSingleton.getInstance().aliasCommandAsAnotherCommandRoot(`"${truePath}"`, `"${tentativePath}"`, this.workerContext.eventStream);
Copy link

Copilot AI Jun 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The aliasCommandAsAnotherCommandRoot method is called with quoted string parameters in DotnetPathFinder, while tests and other usages expect unquoted values. This discrepancy can result in mismatches when retrieving cached commands; please ensure consistent string formatting.

Suggested change
LocalMemoryCacheSingleton.getInstance().aliasCommandAsAnotherCommandRoot(`"${truePath}"`, `"${tentativePath}"`, this.workerContext.eventStream);
LocalMemoryCacheSingleton.getInstance().aliasCommandAsAnotherCommandRoot(truePath, tentativePath, this.workerContext.eventStream);

Copilot uses AI. Check for mistakes.
}
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,15 @@ export class DotnetFindPathLookupSetting extends DotnetCustomMessageEvent
public readonly eventName = 'DotnetFindPathLookupSetting';
}

export class CacheAliasCreated extends DotnetCustomMessageEvent
{
public readonly eventName = 'CacheAliasCreated';
public getProperties()
{
return { Message: this.eventMessage, ...getDisabledTelemetryOnChance(1) };
};
}

export class DotnetFindPathSettingFound extends DotnetCustomMessageEvent
{
public readonly eventName = 'DotnetFindPathSettingFound';
Expand Down
61 changes: 51 additions & 10 deletions vscode-dotnet-runtime-library/src/LocalMemoryCacheSingleton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

import * as nodeCache from 'node-cache';
import { IAcquisitionWorkerContext } from "./Acquisition/IAcquisitionWorkerContext";
import { CacheClearEvent, CacheGetEvent, CachePutEvent } from "./EventStream/EventStreamEvents";
import { TelemetryUtilities } from './EventStream/TelemetryUtilities';
import { IEventStream } from './EventStream/EventStream';
import { CacheAliasCreated, CacheClearEvent, CacheGetEvent, CachePutEvent } from "./EventStream/EventStreamEvents";
import { CommandExecutor } from "./Utils/CommandExecutor";
import { CommandExecutorCommand } from "./Utils/CommandExecutorCommand";
import { CommandExecutorResult } from "./Utils/CommandExecutorResult";
Expand All @@ -31,6 +31,8 @@ export class LocalMemoryCacheSingleton

protected cache: nodeCache = new nodeCache();

private commandRootAliases: Map<string, string> = new Map<string, string>();

protected constructor(public readonly timeToLiveMultiplier = 1)
{

Expand Down Expand Up @@ -67,6 +69,10 @@ export class LocalMemoryCacheSingleton

public getCommand(key: CacheableCommand, context: IAcquisitionWorkerContext): CommandExecutorResult | undefined
{
if (this.commandRootAliases.has(key.command.commandRoot))
{
key.command.commandRoot = this.commandRootAliases.get(key.command.commandRoot) ?? key.command.commandRoot;
}
return this.get(this.cacheableCommandToKey(key), context);
}

Expand All @@ -93,9 +99,28 @@ export class LocalMemoryCacheSingleton
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const ttl = key.options?.dotnetInstallToolCacheTtlMs ?? 5000;
if (this.commandRootAliases.has(key.command.commandRoot))
{
key.command.commandRoot = this.commandRootAliases.get(key.command.commandRoot) ?? key.command.commandRoot;
}

return this.put(this.cacheableCommandToKey(key), obj, { ttlMs: ttl } as LocalMemoryCacheMetadata, context);
}

/**
*
* @param commandRootAlias The root of the command that will result in the same output. Example: if we know "dotnet" is "C:\\Program Files\\dotnet\\dotnet.exe"
* @param aliasedCommandRoot The identical command that had likely already been cached.
* @remarks This does not work in the opposite direction. If you alias "dotnet" to "dotnet2", it will not alias "dotnet2" to "dotnet".
* Trying that will result in neither command being cache aliased to one another.
* Basically, this will replace all command checks in the cache that have the commandRoot of `commandRootAlias` with the `aliasedCommandRoot`.
*/
public aliasCommandAsAnotherCommandRoot(commandRootAlias: string, aliasedCommandRoot: string, eventStream: IEventStream): void
{
eventStream.post(new CacheAliasCreated(`Aliasing command root ${commandRootAlias} to ${aliasedCommandRoot}`));
this.commandRootAliases.set(commandRootAlias, aliasedCommandRoot);
}

public invalidate(context?: IAcquisitionWorkerContext): void
{
context?.eventStream.post(new CacheClearEvent(`Wiping the cache at ${new Date().toISOString()}`));
Expand All @@ -104,19 +129,35 @@ export class LocalMemoryCacheSingleton

private cacheableCommandToKey(key: CacheableCommand): string
{
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return `${CommandExecutor.prettifyCommandExecutorCommand(key.command)}${JSON.stringify(key.options, function replacer(k, v)
// Get all keys sorted
const sortedKeys = Object.keys(key.options).sort();

// Build ordered string manually
let optionsString = '{';
sortedKeys.forEach((k, index) =>
{
// Replace the dotnetInstallToolCacheTtlMs key with undefined so that it doesn't affect the cache key.
const v = key.options[k];
// Apply same replacer logic
let value = v;
if (k === 'dotnetInstallToolCacheTtlMs')
{
return undefined;
value = undefined;
} else if (k === 'env')
{
value = minimizeEnvironment(v);
}
else if (k === 'env')

if (value !== undefined)
{
return `${minimizeEnvironment(v)}`;
optionsString += `"${k}":${JSON.stringify(value)}`;
if (index < sortedKeys.length - 1)
{
optionsString += ',';
}
}
return v;
})}`;
});
optionsString += '}';

return `${CommandExecutor.prettifyCommandExecutorCommand(key.command)}${optionsString}`;
}
};
Loading