Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use the .NET runtime extension to find an appropriate .NET install #7684

Merged
merged 12 commits into from
Nov 21, 2024
3 changes: 3 additions & 0 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
".NET Test Log": ".NET Test Log",
".NET NuGet Restore": ".NET NuGet Restore",
"Update and reload": "Update and reload",
"The {0} extension requires version {1} or greater of the .NET Install Tool ({2}) extension. Please update to continue": "The {0} extension requires version {1} or greater of the .NET Install Tool ({2}) extension. Please update to continue",
dibarbet marked this conversation as resolved.
Show resolved Hide resolved
"Version {0} of the .NET Install Tool ({1}) was not found, will not activate.": "Version {0} of the .NET Install Tool ({1}) was not found, will not activate.",
"How to setup Remote Debugging": "How to setup Remote Debugging",
"The C# extension for Visual Studio Code is incompatible on {0} {1} with the VS Code Remote Extensions. To see avaliable workarounds, click on '{2}'.": "The C# extension for Visual Studio Code is incompatible on {0} {1} with the VS Code Remote Extensions. To see avaliable workarounds, click on '{2}'.",
"The C# extension for Visual Studio Code is incompatible on {0} {1}.": "The C# extension for Visual Studio Code is incompatible on {0} {1}.",
Expand Down
50 changes: 50 additions & 0 deletions src/lsptoolshost/dotnetRuntimeExtensionApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// Contains APIs defined by the vscode-dotnet-runtime extension

export interface IDotnetAcquireResult {
dotnetPath: string;
}

export interface IDotnetFindPathContext {
acquireContext: IDotnetAcquireContext;
versionSpecRequirement: DotnetVersionSpecRequirement;
}

/**
* https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/IDotnetAcquireContext.ts
*/
interface IDotnetAcquireContext {
version: string;
requestingExtensionId?: string;
errorConfiguration?: AcquireErrorConfiguration;
installType?: DotnetInstallType;
architecture?: string | null | undefined;
mode?: DotnetInstallMode;
}

/**
* https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/IDotnetAcquireContext.ts#L53C8-L53C52
*/
type DotnetInstallType = 'local' | 'global';

/**
* https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/Utils/ErrorHandler.ts#L22
*/
enum AcquireErrorConfiguration {
DisplayAllErrorPopups = 0,
DisableErrorPopups = 1,
}

/**
* https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/Acquisition/DotnetInstallMode.ts
*/
type DotnetInstallMode = 'sdk' | 'runtime' | 'aspnetcore';

/**
* https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/DotnetVersionSpecRequirement.ts
*/
type DotnetVersionSpecRequirement = 'equal' | 'greater_than_or_equal' | 'less_than_or_equal';
171 changes: 48 additions & 123 deletions src/lsptoolshost/dotnetRuntimeExtensionResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,19 @@

import * as path from 'path';
import * as vscode from 'vscode';
import * as semver from 'semver';
import { HostExecutableInformation } from '../shared/constants/hostExecutableInformation';
import { IHostExecutableResolver } from '../shared/constants/IHostExecutableResolver';
import { PlatformInformation } from '../shared/platform';
import { commonOptions, languageServerOptions } from '../shared/options';
import { existsSync } from 'fs';
import { CSharpExtensionId } from '../constants/csharpExtensionId';
import { getDotnetInfo } from '../shared/utils/getDotnetInfo';
import { readFile } from 'fs/promises';
import { RuntimeInfo } from '../shared/utils/dotnetInfo';
import { IDotnetAcquireResult, IDotnetFindPathContext } from './dotnetRuntimeExtensionApi';

export const DotNetRuntimeVersion = '8.0.10';

interface IDotnetAcquireResult {
dotnetPath: string;
}
const DotNetMajorVersion = '8';
const DotNetMinorVersion = '0';
const DotNetPatchVersion = '10';
export const DotNetRuntimeVersion = `${DotNetMajorVersion}.${DotNetMinorVersion}.${DotNetPatchVersion}`;

/**
* Resolves the dotnet runtime for a server executable from given options and the dotnet runtime VSCode extension.
Expand All @@ -39,38 +36,47 @@ export class DotnetRuntimeExtensionResolver implements IHostExecutableResolver {
private hostInfo: HostExecutableInformation | undefined;

async getHostExecutableInfo(): Promise<HostExecutableInformation> {
let dotnetRuntimePath = commonOptions.dotnetPath;
Copy link
Member

@nagilson nagilson Nov 19, 2024

Choose a reason for hiding this comment

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

A breaking change document for dotnetPath should be created, IMO - though for later of course. I would also make sure this is remarked in the release notes, and that our existingPath setting is mentioned.
https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.vscode-dotnet-runtime&ssr=false#overview:~:text=I%20already%20have%20a%20.NET%20Runtime%20or%20SDK%20installed%2C%20and%20I%20want%20to%20use%20it

cc @baronfel @webreidi for awareness.

Copy link
Member Author

Choose a reason for hiding this comment

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

I haven't removed this in this PR - plan to in a followup. What might be an even better option is to migrate existing users of dotnet.dotnetPath to the new setting. We already do that for migrating O# options

const serverPath = this.getServerPath(this.platformInfo);

// Check if we can find a valid dotnet from dotnet --version on the PATH.
if (!dotnetRuntimePath) {
const dotnetPath = await this.findDotnetFromPath();
if (dotnetPath) {
return {
version: '' /* We don't need to know the version - we've already verified its high enough */,
path: dotnetPath,
env: this.getEnvironmentVariables(dotnetPath),
};
let dotnetExecutablePath: string;
if (commonOptions.dotnetPath) {
Copy link
Member Author

Choose a reason for hiding this comment

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

this should be deprecated in favor of the options on the .net install tool side. However I plan to tackle that in a separate PR as we'll need to separate out the O# usage of this option.

Copy link
Member

Choose a reason for hiding this comment

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

I thought about whether we should keep the existing logic as a backup, but if this has about a mo in pre-release I'm not so concerned.

const dotnetExecutableName = this.getDotnetExecutableName();
dotnetExecutablePath = path.join(commonOptions.dotnetPath, dotnetExecutableName);
} else {
if (this.hostInfo) {
return this.hostInfo;
}
}

// We didn't find it on the path, see if we can install the correct runtime using the runtime extension.
if (!dotnetRuntimePath) {
const dotnetInfo = await this.acquireDotNetProcessDependencies(serverPath);
dotnetRuntimePath = path.dirname(dotnetInfo.path);
}
this.channel.appendLine(`Locating .NET runtime version ${DotNetRuntimeVersion}`);
const extensionArchitecture = (await this.getArchitectureFromTargetPlatform()) ?? process.arch;
const findPathRequest: IDotnetFindPathContext = {
acquireContext: {
version: DotNetRuntimeVersion,
requestingExtensionId: CSharpExtensionId,
architecture: extensionArchitecture,
mode: 'runtime',
},
versionSpecRequirement: 'greater_than_or_equal',
};
let acquireResult = await vscode.commands.executeCommand<IDotnetAcquireResult | undefined>(
'dotnet.findPath',
findPathRequest
);
if (acquireResult === undefined) {
this.channel.appendLine(
`Did not find .NET ${DotNetRuntimeVersion} on path, falling back to acquire runtime via ms-dotnettools.vscode-dotnet-runtime`
);
acquireResult = await this.acquireDotNetProcessDependencies();
}

const dotnetExecutableName = this.getDotnetExecutableName();
const dotnetExecutablePath = path.join(dotnetRuntimePath, dotnetExecutableName);
if (!existsSync(dotnetExecutablePath)) {
throw new Error(`Cannot find dotnet path '${dotnetExecutablePath}'`);
dotnetExecutablePath = acquireResult.dotnetPath;
}

return {
const hostInfo = {
version: '' /* We don't need to know the version - we've already downloaded the correct one */,
path: dotnetExecutablePath,
env: this.getEnvironmentVariables(dotnetExecutablePath),
};
this.hostInfo = hostInfo;
return hostInfo;
}

private getEnvironmentVariables(dotnetExecutablePath: string): NodeJS.ProcessEnv {
Expand Down Expand Up @@ -100,14 +106,10 @@ export class DotnetRuntimeExtensionResolver implements IHostExecutableResolver {
* Acquires the .NET runtime if it is not already present.
* @returns The path to the .NET runtime
*/
private async acquireRuntime(): Promise<HostExecutableInformation> {
if (this.hostInfo) {
return this.hostInfo;
}

// We have to use '8.0' here because the runtme extension doesn't support acquiring patch versions.
// The acquisition will always acquire the latest however, so it will be at least 8.0.10.
const dotnetAcquireVersion = '8.0';
private async acquireRuntime(): Promise<IDotnetAcquireResult> {
// The runtime extension doesn't support specifying a patch versions in the acquire API, so we only use major.minor here.
// That is generally OK, as acquisition will always acquire the latest patch version.
const dotnetAcquireVersion = `${DotNetMajorVersion}.${DotNetMinorVersion}`;
let status = await vscode.commands.executeCommand<IDotnetAcquireResult>('dotnet.acquireStatus', {
version: dotnetAcquireVersion,
requestingExtensionId: CSharpExtensionId,
Expand All @@ -119,106 +121,29 @@ export class DotnetRuntimeExtensionResolver implements IHostExecutableResolver {
version: dotnetAcquireVersion,
requestingExtensionId: CSharpExtensionId,
});
if (!status?.dotnetPath) {
if (!status) {
throw new Error('Could not resolve the dotnet path!');
}
}

return (this.hostInfo = {
version: DotNetRuntimeVersion,
path: status.dotnetPath,
env: process.env,
});
return status;
}

/**
* Acquires the .NET runtime and any other dependencies required to spawn a particular .NET executable.
* @param path The path to the entrypoint assembly. Typically a .dll.
*/
private async acquireDotNetProcessDependencies(path: string): Promise<HostExecutableInformation> {
const dotnetInfo = await this.acquireRuntime();
private async acquireDotNetProcessDependencies(): Promise<IDotnetAcquireResult> {
const acquireResult = await this.acquireRuntime();

const args = [path];
const args = [this.getServerPath(this.platformInfo)];
// This will install any missing Linux dependencies.
await vscode.commands.executeCommand('dotnet.ensureDotnetDependencies', {
command: dotnetInfo.path,
command: acquireResult.dotnetPath,
arguments: args,
});

return dotnetInfo;
}

/**
* Checks dotnet --version to see if the value on the path is greater than the minimum required version.
* This is adapated from similar O# server logic and should be removed when we have a stable acquisition extension.
* @returns true if the dotnet version is greater than the minimum required version, false otherwise.
*/
private async findDotnetFromPath(): Promise<string | undefined> {
try {
const dotnetInfo = await getDotnetInfo([]);

const extensionArchitecture = await this.getArchitectureFromTargetPlatform();
const dotnetArchitecture = dotnetInfo.Architecture;

// If the extension arhcitecture is defined, we check that it matches the dotnet architecture.
// If its undefined we likely have a platform neutral server and assume it can run on any architecture.
if (extensionArchitecture && extensionArchitecture !== dotnetArchitecture) {
throw new Error(
`The architecture of the .NET runtime (${dotnetArchitecture}) does not match the architecture of the extension (${extensionArchitecture}).`
dibarbet marked this conversation as resolved.
Show resolved Hide resolved
);
}

// Verify that the dotnet we found includes a runtime version that is compatible with our requirement.
const requiredRuntimeVersion = semver.parse(`${DotNetRuntimeVersion}`);
if (!requiredRuntimeVersion) {
throw new Error(`Unable to parse minimum required version ${DotNetRuntimeVersion}`);
}

const coreRuntimeVersions = dotnetInfo.Runtimes['Microsoft.NETCore.App'];
let matchingRuntime: RuntimeInfo | undefined = undefined;
for (const runtime of coreRuntimeVersions) {
// We consider a match if the runtime is greater than or equal to the required version since we roll forward.
if (semver.gte(runtime.Version, requiredRuntimeVersion)) {
matchingRuntime = runtime;
break;
}
}

if (!matchingRuntime) {
throw new Error(
`No compatible .NET runtime found. Minimum required version is ${DotNetRuntimeVersion}.`
);
}

// The .NET install layout is a well known structure on all platforms.
// See https://github.com/dotnet/designs/blob/main/accepted/2020/install-locations.md#net-core-install-layout
//
// Therefore we know that the runtime path is always in <install root>/shared/<runtime name>
// and the dotnet executable is always at <install root>/dotnet(.exe).
//
// 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.
const runtimeFolderPath = matchingRuntime.Path;
const installFolder = path.dirname(path.dirname(runtimeFolderPath));
const dotnetExecutablePath = path.join(installFolder, this.getDotnetExecutableName());
if (!existsSync(dotnetExecutablePath)) {
throw new Error(
`dotnet executable path does not exist: ${dotnetExecutablePath}, dotnet installation may be corrupt.`
);
}

this.channel.appendLine(`Using dotnet configured on PATH`);
return dotnetExecutablePath;
} catch (e) {
this.channel.appendLine(
'Failed to find dotnet info from path, falling back to acquire runtime via ms-dotnettools.vscode-dotnet-runtime'
);
if (e instanceof Error) {
this.channel.appendLine(e.message);
}
}

return undefined;
return acquireResult;
}

private async getArchitectureFromTargetPlatform(): Promise<string | undefined> {
Expand Down
29 changes: 29 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { debugSessionTracker } from './coreclrDebug/provisionalDebugSessionTrack
import { getComponentFolder } from './lsptoolshost/builtInComponents';
import { activateOmniSharpLanguageServer, ActivationResult } from './omnisharp/omnisharpLanguageServer';
import { ActionOption, showErrorMessage } from './shared/observers/utils/showMessage';
import { lt } from 'semver';
import { TelemetryEventNames } from './shared/telemetryEventNames';

export async function activate(
Expand Down Expand Up @@ -82,6 +83,34 @@ export async function activate(
requiredPackageIds.push('OmniSharp');
}

const dotnetRuntimeExtensionId = 'ms-dotnettools.vscode-dotnet-runtime';
const requiredDotnetRuntimeExtensionVersion = '2.2.3';

const dotnetRuntimeExtension = vscode.extensions.getExtension(dotnetRuntimeExtensionId);
dibarbet marked this conversation as resolved.
Show resolved Hide resolved
const dotnetRuntimeExtensionVersion = dotnetRuntimeExtension?.packageJSON.version;
if (lt(dotnetRuntimeExtensionVersion, requiredDotnetRuntimeExtensionVersion)) {
const button = vscode.l10n.t('Update and reload');
const prompt = vscode.l10n.t(
'The {0} extension requires version {1} or greater of the .NET Install Tool ({2}) extension. Please update to continue',
dibarbet marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

@nagilson nagilson Nov 19, 2024

Choose a reason for hiding this comment

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

Some people who install C# have no idea what .NET is, so we might expect some amount of confusion from users who don't understand why they would need a '.NET Install Tool' -- it may be helpful to include more context, but people don't tend to read long error messages, so this is likely better. Might be worth PM consultant.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah - I think this is already fairly long and am hesitant to increase it. cc @webreidi for thoughts.

context.extension.packageJSON.displayName,
requiredDotnetRuntimeExtensionVersion,
dotnetRuntimeExtensionId
);
const selection = await vscode.window.showErrorMessage(prompt, button);
if (selection === button) {
await vscode.commands.executeCommand('workbench.extensions.installExtension', dotnetRuntimeExtensionId);
await vscode.commands.executeCommand('workbench.action.reloadWindow');
} else {
throw new Error(
vscode.l10n.t(
'Version {0} of the .NET Install Tool ({1}) was not found, will not activate.',
dibarbet marked this conversation as resolved.
Show resolved Hide resolved
requiredDotnetRuntimeExtensionVersion,
dotnetRuntimeExtensionId
)
);
}
}

// If the dotnet bundle is installed, this will ensure the dotnet CLI is on the path.
await initializeDotnetPath();

Expand Down
Loading