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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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 l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"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",
"Version {0} of the .NET Install Tool ({2}) was not found, will not activate.": "Version {0} of the .NET Install Tool ({2}) 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';
181 changes: 24 additions & 157 deletions src/lsptoolshost/dotnetRuntimeExtensionResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,72 +5,61 @@

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 { IDotnetFindPathContext } from './dotnetRuntimeExtensionApi';

export const DotNetRuntimeVersion = '8.0.10';

interface IDotnetAcquireResult {
dotnetPath: string;
}

/**
* Resolves the dotnet runtime for a server executable from given options and the dotnet runtime VSCode extension.
*/
export class DotnetRuntimeExtensionResolver implements IHostExecutableResolver {
constructor(
private platformInfo: PlatformInformation,
/**
* This is a function instead of a string because the server path can change while the extension is active (when the option changes).
*/
private getServerPath: (platform: PlatformInformation) => string,
private channel: vscode.OutputChannel,
private extensionPath: string
) {}

private hostInfo: HostExecutableInformation | undefined;

async getHostExecutableInfo(): Promise<HostExecutableInformation> {
let dotnetRuntimePath = commonOptions.dotnetPath;
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.

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);
}

const dotnetExecutableName = this.getDotnetExecutableName();
const dotnetExecutablePath = path.join(dotnetRuntimePath, dotnetExecutableName);
if (!existsSync(dotnetExecutablePath)) {
throw new Error(`Cannot find dotnet path '${dotnetExecutablePath}'`);
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',
};
const result = await vscode.commands.executeCommand<string>('dotnet.findPath', findPathRequest);
Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

It can return an IDotnetAcquireResult if the existingDotnetPath is set. It should always return an IDotnetAcquireResult. This was a mistake on my part, thank you for bringing this to my attention. I will make a fix.

dotnetExecutablePath = result;
}

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 All @@ -96,128 +85,6 @@ export class DotnetRuntimeExtensionResolver implements IHostExecutableResolver {
return env;
}

/**
* 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;
}

let status = await vscode.commands.executeCommand<IDotnetAcquireResult>('dotnet.acquireStatus', {
version: DotNetRuntimeVersion,
requestingExtensionId: CSharpExtensionId,
});
if (status === undefined) {
await vscode.commands.executeCommand('dotnet.showAcquisitionLog');

status = await vscode.commands.executeCommand<IDotnetAcquireResult>('dotnet.acquire', {
version: DotNetRuntimeVersion,
requestingExtensionId: CSharpExtensionId,
});
if (!status?.dotnetPath) {
throw new Error('Could not resolve the dotnet path!');
}
}

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

/**
* 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();

const args = [path];
// This will install any missing Linux dependencies.
await vscode.commands.executeCommand('dotnet.ensureDotnetDependencies', {
command: dotnetInfo.path,
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}).`
);
}

// 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;
}

private async getArchitectureFromTargetPlatform(): Promise<string | undefined> {
const vsixManifestFile = path.join(this.extensionPath, '.vsixmanifest');
if (!existsSync(vsixManifestFile)) {
Expand Down
1 change: 0 additions & 1 deletion src/lsptoolshost/roslynLanguageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1051,7 +1051,6 @@ export async function activateRoslynLanguageServer(

const hostExecutableResolver = new DotnetRuntimeExtensionResolver(
platformInfo,
getServerPath,
outputChannel,
context.extensionPath
);
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';

export async function activate(
context: vscode.ExtensionContext
Expand Down Expand Up @@ -83,6 +84,34 @@ export async function activate(
requiredPackageIds.push('OmniSharp');
}

const dotnetRuntimeExtensionId = 'ms-dotnettools.vscode-dotnet-runtime';
const requiredDotnetRuntimeExtensionVersion = '2.2.1';
Copy link
Member

Choose a reason for hiding this comment

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

Too bad this can't be expressed in the package.json

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, unfortunately no versions in extension dependencies :(


const dotnetRuntimeExtension = vscode.extensions.getExtension(dotnetRuntimeExtensionId);
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',
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 ({2}) was not found, will not activate.',
requiredDotnetRuntimeExtensionVersion,
dotnetRuntimeExtensionId
)
);
}
}

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

Expand Down
10 changes: 0 additions & 10 deletions src/razor/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ import TelemetryReporter from '@vscode/extension-telemetry';
import { CSharpDevKitExports } from '../../csharpDevKitExports';
import { DotnetRuntimeExtensionResolver } from '../../lsptoolshost/dotnetRuntimeExtensionResolver';
import { PlatformInformation } from '../../shared/platform';
import { RazorLanguageServerOptions } from './razorLanguageServerOptions';
import { resolveRazorLanguageServerOptions } from './razorLanguageServerOptionsResolver';
import { RazorFormatNewFileHandler } from './formatNewFile/razorFormatNewFileHandler';
import { InlayHintHandler } from './inlayHint/inlayHintHandler';
import { InlayHintResolveHandler } from './inlayHint/inlayHintResolveHandler';
Expand All @@ -75,16 +73,8 @@ export async function activate(
const logger = new RazorLogger(eventEmitterFactory, languageServerLogLevel);

try {
const razorOptions: RazorLanguageServerOptions = resolveRazorLanguageServerOptions(
vscodeType,
languageServerDir,
languageServerLogLevel,
logger
);

const hostExecutableResolver = new DotnetRuntimeExtensionResolver(
platformInfo,
() => razorOptions.serverPath,
logger.outputChannel,
context.extensionPath
);
Expand Down
Loading