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
136 changes: 125 additions & 11 deletions src/coreclrDebug/activate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { BaseVsDbgConfigurationProvider } from '../shared/configurationProvider'
import { omnisharpOptions } from '../shared/options';
import { ActionOption, CommandOption, showErrorMessage } from '../shared/observers/utils/showMessage';
import { getCSharpDevKit } from '../utils/getCSharpDevKit';
import { CSharpDevKitExports, WorkspaceDotnetHost } from '../csharpDevKitExports';

export async function activate(
thisExtension: vscode.Extension<any>,
Expand Down Expand Up @@ -174,13 +175,25 @@ async function checkIsValidArchitecture(
return false;
}

async function completeDebuggerInstall(
export async function completeDebuggerInstall(
debugUtil: CoreClrDebugUtil,
platformInformation: PlatformInformation,
eventStream: EventStream
): Promise<boolean> {
try {
await debugUtil.checkDotNetCli(omnisharpOptions.dotNetCliPaths);
const workspaceHost = await resolveWorkspaceDotnetHost();
if (workspaceHost?.status === 'blocked') {
return false;
}

if (workspaceHost?.status === 'ready') {
await debugUtil.checkDotNetCli([], {
dotnetExecutablePath: workspaceHost.dotnetPath,
environment: workspaceHost.environment,
});
} else {
await debugUtil.checkDotNetCli(omnisharpOptions.dotNetCliPaths);
}
const isValidArchitecture = await checkIsValidArchitecture(platformInformation, eventStream);
if (!isValidArchitecture) {
eventStream.post(new DebuggerNotInstalledFailure());
Expand Down Expand Up @@ -208,6 +221,47 @@ async function completeDebuggerInstall(
}
}

const DEV_KIT_HOST_TIMEOUT_MS = 90_000;

type WorkspaceDotnetExtension = {
activate(): Thenable<Pick<CSharpDevKitExports, 'dotnet'> | undefined>;
};

export async function resolveWorkspaceDotnetHost(
csharpDevKit: WorkspaceDotnetExtension | null | undefined = getCSharpDevKit(),
timeoutMs = DEV_KIT_HOST_TIMEOUT_MS
): Promise<WorkspaceDotnetHost | undefined> {
if (!csharpDevKit) {
return undefined;
}

let timedOut = false;
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
Promise.resolve()
.then(async () => await csharpDevKit.activate())
.then(async (exports) => {
if (timedOut) {
return undefined;
}
return await exports?.dotnet?.getWorkspaceDotnetHost?.();
})
.catch(() => undefined),
new Promise<undefined>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
resolve(undefined);
}, timeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}

function showInstallErrorMessage(eventStream: EventStream) {
eventStream.post(new DebuggerNotInstalledFailure());
showErrorMessage(
Expand Down Expand Up @@ -317,7 +371,23 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip

// use the executable specified in the package.json if it exists or determine it based on some other information (e.g. the session)
if (!executable) {
const dotNetInfo = await getDotnetInfo(omnisharpOptions.dotNetCliPaths);
const workspaceHost = await resolveWorkspaceDotnetHost();
if (workspaceHost?.status === 'blocked') {
this.eventStream.post(new DebuggerNotInstalledFailure());
throw new Error(
vscode.l10n.t(
'Failed to complete the installation of the C# extension. Please see the error in the output window below.'
)
);
}

const dotNetInfo =
workspaceHost?.status === 'ready'
? await getDotnetInfo([], {
dotnetExecutablePath: workspaceHost.dotnetPath,
environment: workspaceHost.environment,
})
: await getDotnetInfo(omnisharpOptions.dotNetCliPaths);
const targetArchitecture = getTargetArchitecture(
this.platformInfo,
_session.configuration.targetArchitecture,
Expand All @@ -330,17 +400,27 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip
'vsdbg-ui' + CoreClrDebugUtil.getPlatformExeExtension()
);

// Look to see if DOTNET_ROOT is set, then use dotnet cli path
const dotnetRoot: string =
process.env.DOTNET_ROOT ?? (dotNetInfo.CliPath ? path.dirname(dotNetInfo.CliPath) : '');

let options: vscode.DebugAdapterExecutableOptions | undefined = undefined;
if (dotnetRoot) {
if (workspaceHost?.status === 'ready') {
const workspaceEnvironment = createDebugAdapterEnvironment(
workspaceHost.environment,
workspaceHost.dotnetPath,
this.platformInfo.isWindows()
);

options = {
env: {
DOTNET_ROOT: dotnetRoot,
},
// VS Code merges this object with its environment before spawning. Undefined values survive that
// merge and are omitted by Node, preserving the producer's explicit null removals.
env: workspaceEnvironment as vscode.DebugAdapterExecutableOptions['env'],
};
} else {
const dotnetRoot =
process.env.DOTNET_ROOT ?? (dotNetInfo.CliPath ? path.dirname(dotNetInfo.CliPath) : '');
if (dotnetRoot) {
options = {
env: { DOTNET_ROOT: dotnetRoot },
};
}
}

executable = new vscode.DebugAdapterExecutable(command, [], options);
Expand All @@ -350,3 +430,37 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip
return executable;
}
}

function createDebugAdapterEnvironment(
contribution: Readonly<Record<string, string | null>>,
dotnetPath: string,
isWindows: boolean
): NodeJS.ProcessEnv {
const effectiveContribution = hasEnvironmentVariable(contribution, 'DOTNET_ROOT', isWindows)
? contribution
: { ...contribution, DOTNET_ROOT: path.dirname(dotnetPath) };
const knownKeys = new Set([...Object.keys(process.env), ...Object.keys(effectiveContribution)]);
const environment: NodeJS.ProcessEnv = {};

for (const [key, value] of Object.entries(effectiveContribution)) {
for (const knownKey of knownKeys) {
if (environmentVariableNamesEqual(knownKey, key, isWindows)) {
environment[knownKey] = value ?? undefined;
}
}
}

return environment;
}

function hasEnvironmentVariable(
environment: Readonly<Record<string, string | null>>,
name: string,
isWindows: boolean
): boolean {
return Object.keys(environment).some((key) => environmentVariableNamesEqual(key, name, isWindows));
}

function environmentVariableNamesEqual(left: string, right: string, isWindows: boolean): boolean {
return isWindows ? left.toUpperCase() === right.toUpperCase() : left === right;
}
9 changes: 7 additions & 2 deletions src/coreclrDebug/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { PlatformInformation } from '../shared/platform';
import { getDotnetInfo } from '../shared/utils/getDotnetInfo';
import { DotnetInfo } from '../shared/utils/dotnetInfo';

export interface DotnetCliCheckOptions {
dotnetExecutablePath: string;
environment?: Readonly<Record<string, string | null>>;
}

const MINIMUM_SUPPORTED_DOTNET_CLI = '1.0.0';

// .NET 8 requires macOS 12+, however the build machines are on macOS 13, which is Darwin 22.0+
Expand Down Expand Up @@ -65,9 +70,9 @@ export class CoreClrDebugUtil {

// This function checks for the presence of dotnet on the path and ensures the Version
// is new enough for us.
public async checkDotNetCli(dotNetCliPaths: string[]): Promise<void> {
public async checkDotNetCli(dotNetCliPaths: string[], options?: DotnetCliCheckOptions): Promise<void> {
try {
const dotnetInfo = await getDotnetInfo(dotNetCliPaths);
const dotnetInfo = await getDotnetInfo(dotNetCliPaths, options);
if (semver.lt(dotnetInfo.Version, MINIMUM_SUPPORTED_DOTNET_CLI)) {
throw new Error(
vscode.l10n.t(
Expand Down
42 changes: 42 additions & 0 deletions src/csharpDevKitExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,53 @@ import * as vscode from 'vscode';

import { IServiceBroker } from '@microsoft/servicehub-framework';

/**
* Environment overrides required by a selected workspace host. A string sets or replaces a variable; null removes
* an inherited variable from child processes.
*/
export type WorkspaceDotnetEnvironment = Readonly<Record<string, string | null>>;

export type WorkspaceDotnetHost =
| {
readonly status: 'ready';
readonly dotnetPath: string;
readonly environment: WorkspaceDotnetEnvironment;
}
| { readonly status: 'blocked' }
| { readonly status: 'not-applicable' };

export interface WorkspaceSdkInfo {
readonly executablePath: string;
readonly sdkPath: string;
readonly sdkVersion: string;
readonly architecture: string;
readonly environment: WorkspaceDotnetEnvironment;
}

export interface WorkspaceDotnetService {
/**
* Contract version 0.1. Consumers must feature-detect optional members so producers can add compatible 0.1
* capabilities without breaking older extensions.
*/
readonly version: '0.1';
/** Returns the ready workspace SDK, or undefined while selection is unresolved or blocked. */
getSdkInfo(): WorkspaceSdkInfo | undefined;
/** Fires when a selection becomes ready or refreshed SDK metadata is available for the same host. */
readonly onDidChangeSdkInfo: vscode.Event<WorkspaceSdkInfo>;
/**
* Resolves the settled workspace host state when supported by the service producer. A ready result identifies
* the exact executable and environment that consumers must use together.
*/
getWorkspaceDotnetHost?(): Promise<WorkspaceDotnetHost>;
}

export interface CSharpDevKitExports {
serviceBroker: IServiceBroker;
getBrokeredServiceServerPipeName: () => Promise<string>;
components: Readonly<{ [key: string]: string }>;
hasServerProcessLoaded: () => boolean;
serverProcessLoaded: vscode.Event<void>;
setupTelemetryEnvironmentAsync: (env: NodeJS.ProcessEnv) => Promise<string | undefined>;
/** The authoritative .NET SDK selected for this workspace. */
dotnet?: WorkspaceDotnetService;
}
50 changes: 42 additions & 8 deletions src/shared/utils/getDotnetInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,41 @@ import { DotnetInfo, RuntimeInfo } from './dotnetInfo';
import { EOL } from 'os';

// This function calls `dotnet --info` and returns the result as a DotnetInfo object.
export async function getDotnetInfo(dotNetCliPaths: string[]): Promise<DotnetInfo> {
const dotnetExecutablePath = getDotNetExecutablePath(dotNetCliPaths);
export async function getDotnetInfo(
dotNetCliPaths: string[],
options?: {
dotnetExecutablePath?: string;
environment?: Readonly<Record<string, string | null>>;
}
): Promise<DotnetInfo> {
const dotnetExecutablePath = options?.dotnetExecutablePath ?? getDotNetExecutablePath(dotNetCliPaths);
const environment = applyEnvironment(process.env, options?.environment);

const data = await runDotnetInfo(dotnetExecutablePath);
const dotnetInfo = await parseDotnetInfo(data, dotnetExecutablePath);
const data = await runDotnetInfo(dotnetExecutablePath, environment);
const dotnetInfo = await parseDotnetInfo(data, dotnetExecutablePath, environment);
return dotnetInfo;
}

function applyEnvironment(
baseEnvironment: NodeJS.ProcessEnv,
contribution: Readonly<Record<string, string | null>> | undefined
): NodeJS.ProcessEnv {
const environment = { ...baseEnvironment };
for (const [key, value] of Object.entries(contribution ?? {})) {
for (const existingKey of Object.keys(environment)) {
const matches =
process.platform === 'win32' ? existingKey.toUpperCase() === key.toUpperCase() : existingKey === key;
if (matches) {
delete environment[existingKey];
}
}
if (value !== null) {
environment[key] = value;
}
}
return environment;
}

export function getDotNetExecutablePath(dotNetCliPaths: string[]): string | undefined {
const dotnetExeName = `dotnet${CoreClrDebugUtil.getPlatformExeExtension()}`;
let dotnetExecutablePath: string | undefined;
Expand All @@ -33,10 +60,13 @@ export function getDotNetExecutablePath(dotNetCliPaths: string[]): string | unde
return dotnetExecutablePath;
}

async function runDotnetInfo(dotnetExecutablePath: string | undefined): Promise<string> {
async function runDotnetInfo(
dotnetExecutablePath: string | undefined,
environment: NodeJS.ProcessEnv
): Promise<string> {
try {
const env = {
...process.env,
...environment,
DOTNET_CLI_UI_LANGUAGE: 'en-US',
};
const command = dotnetExecutablePath ? `"${dotnetExecutablePath}"` : 'dotnet';
Expand All @@ -48,7 +78,11 @@ async function runDotnetInfo(dotnetExecutablePath: string | undefined): Promise<
}
}

async function parseDotnetInfo(dotnetInfo: string, dotnetExecutablePath: string | undefined): Promise<DotnetInfo> {
async function parseDotnetInfo(
dotnetInfo: string,
dotnetExecutablePath: string | undefined,
environment: NodeJS.ProcessEnv
): Promise<DotnetInfo> {
try {
const cliPath = dotnetExecutablePath;
const fullInfo = dotnetInfo;
Expand All @@ -71,7 +105,7 @@ async function parseDotnetInfo(dotnetInfo: string, dotnetExecutablePath: string

const runtimeVersions: { [runtime: string]: RuntimeInfo[] } = {};
const command = dotnetExecutablePath ? `"${dotnetExecutablePath}"` : 'dotnet';
const listRuntimes = await execChildProcess(`${command} --list-runtimes`, process.cwd(), process.env);
const listRuntimes = await execChildProcess(`${command} --list-runtimes`, process.cwd(), environment);
lines = listRuntimes.split(/\r?\n/);
for (const line of lines) {
let match: RegExpMatchArray | null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { beforeEach, describe, expect, jest, test } from '@jest/globals';
import { execChildProcess } from '../../../../src/common';
import { getDotnetInfo } from '../../../../src/shared/utils/getDotnetInfo';

jest.mock('../../../../src/common', () => ({
execChildProcess: jest.fn(),
}));

const execChildProcessMock = jest.mocked(execChildProcess);

describe('getDotnetInfo selected host', () => {
beforeEach(() => {
execChildProcessMock.mockReset();
execChildProcessMock
.mockResolvedValueOnce('SDK:\n Version: 10.0.100\n RID: linux-x64\n Architecture: x64\n')
.mockResolvedValueOnce('Microsoft.NETCore.App 10.0.0 [/managed/shared/Microsoft.NETCore.App]\n');
});

test('passes the exact managed executable to dotnet --info when PATH has no dotnet', async () => {
const info = await getDotnetInfo([], {
dotnetExecutablePath: '/managed/dotnet',
environment: {
DOTNET_ROOT: '/managed',
PATH: null,
},
});

expect(info.CliPath).toBe('/managed/dotnet');
expect(execChildProcessMock).toHaveBeenNthCalledWith(
1,
'"/managed/dotnet" --info',
process.cwd(),
expect.objectContaining({ DOTNET_ROOT: '/managed', DOTNET_CLI_UI_LANGUAGE: 'en-US' })
);
expect(execChildProcessMock.mock.calls[0][2]).not.toHaveProperty('PATH');
expect(execChildProcessMock).toHaveBeenNthCalledWith(
2,
'"/managed/dotnet" --list-runtimes',
process.cwd(),
expect.objectContaining({ DOTNET_ROOT: '/managed' })
);
});
});
Loading
Loading