diff --git a/src/coreclrDebug/activate.ts b/src/coreclrDebug/activate.ts index 0101c4797a..59c904f003 100644 --- a/src/coreclrDebug/activate.ts +++ b/src/coreclrDebug/activate.ts @@ -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, @@ -174,13 +175,25 @@ async function checkIsValidArchitecture( return false; } -async function completeDebuggerInstall( +export async function completeDebuggerInstall( debugUtil: CoreClrDebugUtil, platformInformation: PlatformInformation, eventStream: EventStream ): Promise { 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()); @@ -208,6 +221,47 @@ async function completeDebuggerInstall( } } +const DEV_KIT_HOST_TIMEOUT_MS = 90_000; + +type WorkspaceDotnetExtension = { + activate(): Thenable | undefined>; +}; + +export async function resolveWorkspaceDotnetHost( + csharpDevKit: WorkspaceDotnetExtension | null | undefined = getCSharpDevKit(), + timeoutMs = DEV_KIT_HOST_TIMEOUT_MS +): Promise { + 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((resolve) => { + timer = setTimeout(() => { + timedOut = true; + resolve(undefined); + }, timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + function showInstallErrorMessage(eventStream: EventStream) { eventStream.post(new DebuggerNotInstalledFailure()); showErrorMessage( @@ -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, @@ -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); @@ -350,3 +430,37 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip return executable; } } + +function createDebugAdapterEnvironment( + contribution: Readonly>, + 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>, + 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; +} diff --git a/src/coreclrDebug/util.ts b/src/coreclrDebug/util.ts index abbb021af1..c5eabe695a 100644 --- a/src/coreclrDebug/util.ts +++ b/src/coreclrDebug/util.ts @@ -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>; +} + 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+ @@ -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 { + public async checkDotNetCli(dotNetCliPaths: string[], options?: DotnetCliCheckOptions): Promise { 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( diff --git a/src/csharpDevKitExports.ts b/src/csharpDevKitExports.ts index 696b43c650..c74766a527 100644 --- a/src/csharpDevKitExports.ts +++ b/src/csharpDevKitExports.ts @@ -7,6 +7,46 @@ 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>; + +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; + /** + * 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; +} + export interface CSharpDevKitExports { serviceBroker: IServiceBroker; getBrokeredServiceServerPipeName: () => Promise; @@ -14,4 +54,6 @@ export interface CSharpDevKitExports { hasServerProcessLoaded: () => boolean; serverProcessLoaded: vscode.Event; setupTelemetryEnvironmentAsync: (env: NodeJS.ProcessEnv) => Promise; + /** The authoritative .NET SDK selected for this workspace. */ + dotnet?: WorkspaceDotnetService; } diff --git a/src/shared/utils/getDotnetInfo.ts b/src/shared/utils/getDotnetInfo.ts index 0aa81889c7..dfb98b8be6 100644 --- a/src/shared/utils/getDotnetInfo.ts +++ b/src/shared/utils/getDotnetInfo.ts @@ -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 { - const dotnetExecutablePath = getDotNetExecutablePath(dotNetCliPaths); +export async function getDotnetInfo( + dotNetCliPaths: string[], + options?: { + dotnetExecutablePath?: string; + environment?: Readonly>; + } +): Promise { + 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> | 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; @@ -33,10 +60,13 @@ export function getDotNetExecutablePath(dotNetCliPaths: string[]): string | unde return dotnetExecutablePath; } -async function runDotnetInfo(dotnetExecutablePath: string | undefined): Promise { +async function runDotnetInfo( + dotnetExecutablePath: string | undefined, + environment: NodeJS.ProcessEnv +): Promise { try { const env = { - ...process.env, + ...environment, DOTNET_CLI_UI_LANGUAGE: 'en-US', }; const command = dotnetExecutablePath ? `"${dotnetExecutablePath}"` : 'dotnet'; @@ -48,7 +78,11 @@ async function runDotnetInfo(dotnetExecutablePath: string | undefined): Promise< } } -async function parseDotnetInfo(dotnetInfo: string, dotnetExecutablePath: string | undefined): Promise { +async function parseDotnetInfo( + dotnetInfo: string, + dotnetExecutablePath: string | undefined, + environment: NodeJS.ProcessEnv +): Promise { try { const cliPath = dotnetExecutablePath; const fullInfo = dotnetInfo; @@ -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; diff --git a/test/omnisharp/omnisharpUnitTests/coreclrDebug/getDotnetInfo.test.ts b/test/omnisharp/omnisharpUnitTests/coreclrDebug/getDotnetInfo.test.ts new file mode 100644 index 0000000000..8a284b581e --- /dev/null +++ b/test/omnisharp/omnisharpUnitTests/coreclrDebug/getDotnetInfo.test.ts @@ -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' }) + ); + }); +}); diff --git a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts new file mode 100644 index 0000000000..3de58b18f6 --- /dev/null +++ b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts @@ -0,0 +1,427 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, jest, test } from '@jest/globals'; +import * as vscode from 'vscode'; +import * as common from '../../../../src/common'; +import { + CSharpDevKitExports, + WorkspaceDotnetHost, + WorkspaceDotnetService, + WorkspaceSdkInfo, +} from '../../../../src/csharpDevKitExports'; +import { + completeDebuggerInstall, + DebugAdapterExecutableFactory, + resolveWorkspaceDotnetHost, +} from '../../../../src/coreclrDebug/activate'; +import { CoreClrDebugUtil } from '../../../../src/coreclrDebug/util'; +import { EventStream } from '../../../../src/eventStream'; +import { omnisharpOptions } from '../../../../src/shared/options'; +import { PlatformInformation } from '../../../../src/shared/platform'; +import { getDotnetInfo } from '../../../../src/shared/utils/getDotnetInfo'; +import { getCSharpDevKit } from '../../../../src/utils/getCSharpDevKit'; + +jest.mock('vscode', () => { + const vscode = jest.requireActual('../../../../__mocks__/vscode'); + return { + ...vscode, + DebugAdapterExecutable: class { + constructor( + public readonly command: string, + public readonly args: readonly string[], + public readonly options?: vscode.DebugAdapterExecutableOptions + ) {} + }, + }; +}); +jest.mock('../../../../src/shared/utils/getDotnetInfo', () => ({ + getDotnetInfo: jest.fn(), +})); +jest.mock('../../../../src/utils/getCSharpDevKit', () => ({ + getCSharpDevKit: jest.fn(), +})); + +const getDotnetInfoMock = jest.mocked(getDotnetInfo); +const getCSharpDevKitMock = jest.mocked(getCSharpDevKit); + +function extensionWithHost(getWorkspaceDotnetHost?: () => Promise) { + const emitter = new vscode.EventEmitter(); + const dotnet: WorkspaceDotnetService = { + version: '0.1', + getSdkInfo: () => undefined, + onDidChangeSdkInfo: emitter.event, + getWorkspaceDotnetHost, + }; + + return { + activate: async () => ({ dotnet }), + }; +} + +describe('completeDebuggerInstall', () => { + test('checks and completes installation with the ready workspace host', async () => { + const debugUtil = new CoreClrDebugUtil('C:\\extension'); + const checkDotNetCli = jest.spyOn(debugUtil, 'checkDotNetCli').mockResolvedValue(); + const writeEmptyFile = jest.spyOn(CoreClrDebugUtil, 'writeEmptyFile').mockResolvedValue(); + const environment = { DOTNET_ROOT: 'C:\\selected', DOTNET_ROOT_X64: null }; + getCSharpDevKitMock.mockReturnValue( + extensionWithHost(async () => ({ + status: 'ready', + dotnetPath: 'C:\\selected\\dotnet.exe', + environment, + })) as unknown as vscode.Extension + ); + + try { + await expect( + completeDebuggerInstall(debugUtil, new PlatformInformation('win32', 'x64'), new EventStream()) + ).resolves.toBe(true); + expect(checkDotNetCli).toHaveBeenCalledWith([], { + dotnetExecutablePath: 'C:\\selected\\dotnet.exe', + environment, + }); + expect(writeEmptyFile).toHaveBeenCalledWith(debugUtil.installCompleteFilePath()); + } finally { + checkDotNetCli.mockRestore(); + writeEmptyFile.mockRestore(); + getCSharpDevKitMock.mockReset(); + } + }); + + test('does not probe or complete installation when the workspace host is blocked', async () => { + const debugUtil = new CoreClrDebugUtil('C:\\extension'); + const checkDotNetCli = jest.spyOn(debugUtil, 'checkDotNetCli').mockResolvedValue(); + const writeEmptyFile = jest.spyOn(CoreClrDebugUtil, 'writeEmptyFile').mockResolvedValue(); + getCSharpDevKitMock.mockReturnValue( + extensionWithHost(async () => ({ status: 'blocked' })) as unknown as vscode.Extension + ); + + try { + await expect( + completeDebuggerInstall(debugUtil, new PlatformInformation('win32', 'x64'), new EventStream()) + ).resolves.toBe(false); + expect(checkDotNetCli).not.toHaveBeenCalled(); + expect(writeEmptyFile).not.toHaveBeenCalled(); + } finally { + checkDotNetCli.mockRestore(); + writeEmptyFile.mockRestore(); + getCSharpDevKitMock.mockReset(); + } + }); + + test('uses standalone probing when the workspace host is not applicable', async () => { + const debugUtil = new CoreClrDebugUtil('C:\\extension'); + const checkDotNetCli = jest.spyOn(debugUtil, 'checkDotNetCli').mockResolvedValue(); + const writeEmptyFile = jest.spyOn(CoreClrDebugUtil, 'writeEmptyFile').mockResolvedValue(); + const dotNetCliPaths = jest.spyOn(omnisharpOptions, 'dotNetCliPaths', 'get').mockReturnValue(['C:\\dotnet']); + getCSharpDevKitMock.mockReturnValue( + extensionWithHost(async () => ({ + status: 'not-applicable', + })) as unknown as vscode.Extension + ); + + try { + await expect( + completeDebuggerInstall(debugUtil, new PlatformInformation('win32', 'x64'), new EventStream()) + ).resolves.toBe(true); + expect(checkDotNetCli).toHaveBeenCalledWith(['C:\\dotnet']); + expect(writeEmptyFile).toHaveBeenCalledWith(debugUtil.installCompleteFilePath()); + } finally { + checkDotNetCli.mockRestore(); + writeEmptyFile.mockRestore(); + dotNetCliPaths.mockRestore(); + getCSharpDevKitMock.mockReset(); + } + }); +}); + +describe('resolveWorkspaceDotnetHost', () => { + test('uses standalone behavior when C# Dev Kit is absent', async () => { + await expect(resolveWorkspaceDotnetHost(null)).resolves.toBeUndefined(); + }); + + test('uses standalone behavior with old C# Dev Kit exports', async () => { + await expect(resolveWorkspaceDotnetHost({ activate: async () => ({}) })).resolves.toBeUndefined(); + }); + + test('uses standalone behavior with the original 0.1 workspace dotnet service', async () => { + await expect(resolveWorkspaceDotnetHost(extensionWithHost())).resolves.toBeUndefined(); + }); + + test('suppresses standalone probing when Workspace Requirements is blocked', async () => { + await expect( + resolveWorkspaceDotnetHost(extensionWithHost(async () => ({ status: 'blocked' }))) + ).resolves.toEqual({ status: 'blocked' }); + }); + + test('returns the exact selected managed host and environment', async () => { + const environment = { DOTNET_ROOT: '/managed', PATH: '/managed' }; + + await expect( + resolveWorkspaceDotnetHost( + extensionWithHost(async () => ({ + status: 'ready', + dotnetPath: '/managed/dotnet', + environment, + })) + ) + ).resolves.toEqual({ status: 'ready', dotnetPath: '/managed/dotnet', environment }); + }); + + test('uses standalone behavior when Workspace Requirements is not applicable', async () => { + await expect( + resolveWorkspaceDotnetHost(extensionWithHost(async () => ({ status: 'not-applicable' }))) + ).resolves.toEqual({ status: 'not-applicable' }); + }); + + test('falls back when C# Dev Kit activation rejects without an unhandled rejection', async () => { + await expect( + resolveWorkspaceDotnetHost({ + activate: async () => Promise.reject(new Error('activation failed')), + }) + ).resolves.toBeUndefined(); + }); + + test('falls back when the optional export rejects', async () => { + await expect( + resolveWorkspaceDotnetHost( + extensionWithHost(async () => Promise.reject(new Error('selection unavailable'))) + ) + ).resolves.toBeUndefined(); + }); + + test('bounds an activation that never settles and falls back', async () => { + jest.useFakeTimers(); + try { + const result = resolveWorkspaceDotnetHost({ activate: async () => new Promise(() => {}) }, 100); + await jest.advanceTimersByTimeAsync(100); + await expect(result).resolves.toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + test('does not request the workspace host when activation completes after the fallback timeout', async () => { + jest.useFakeTimers(); + try { + const getWorkspaceDotnetHost = jest.fn(async (): Promise => ({ status: 'blocked' })); + const extension = extensionWithHost(getWorkspaceDotnetHost); + let completeActivation: (exports: Awaited>) => void = () => {}; + const activation = new Promise>>((resolve) => { + completeActivation = resolve; + }); + + const result = resolveWorkspaceDotnetHost({ activate: async () => activation }, 100); + await jest.advanceTimersByTimeAsync(100); + await expect(result).resolves.toBeUndefined(); + + completeActivation(await extension.activate()); + await jest.runAllTimersAsync(); + expect(getWorkspaceDotnetHost).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); +}); + +describe('DebugAdapterExecutableFactory', () => { + test('launches vsdbg-ui with the selected workspace host environment instead of the ambient root', async () => { + const ambientDotnetRoot = process.env.DOTNET_ROOT; + const existsSync = jest.spyOn(CoreClrDebugUtil, 'existsSync').mockReturnValue(true); + const getExtensionPath = jest.spyOn(common, 'getExtensionPath').mockReturnValue('C:\\extension'); + + try { + process.env.DOTNET_ROOT = 'C:\\ambient'; + const environment = { + DOTNET_ROOT: 'C:\\selected', + DOTNET_HOST_PATH: 'C:\\selected\\dotnet.exe', + DOTNET_MULTILEVEL_LOOKUP: '0', + PATH: 'C:\\selected;C:\\Windows', + DOTNET_ROOT_X64: null, + }; + getCSharpDevKitMock.mockReturnValue( + extensionWithHost(async () => ({ + status: 'ready', + dotnetPath: 'C:\\selected\\dotnet.exe', + environment, + })) as unknown as vscode.Extension + ); + getDotnetInfoMock.mockResolvedValue({ + CliPath: 'C:\\selected\\dotnet.exe', + FullInfo: '', + Version: '10.0.100', + RuntimeId: 'win-x64', + Architecture: 'x64', + Runtimes: {}, + }); + + const factory = new DebugAdapterExecutableFactory( + new CoreClrDebugUtil('C:\\extension'), + new PlatformInformation('win32', 'x64'), + new EventStream(), + {}, + 'C:\\extension' + ); + const executable = (await factory.createDebugAdapterDescriptor( + { configuration: {} } as vscode.DebugSession, + undefined + )) as vscode.DebugAdapterExecutable; + + expect(executable.options?.env).toMatchObject({ + DOTNET_ROOT: 'C:\\selected', + DOTNET_HOST_PATH: 'C:\\selected\\dotnet.exe', + DOTNET_MULTILEVEL_LOOKUP: '0', + PATH: 'C:\\selected;C:\\Windows', + }); + const dotnetRootValues = Object.entries(executable.options?.env ?? {}) + .filter(([key]) => key.toUpperCase() === 'DOTNET_ROOT') + .map(([, value]) => value); + const dotnetRootX64Values = Object.entries(executable.options?.env ?? {}) + .filter(([key]) => key.toUpperCase() === 'DOTNET_ROOT_X64') + .map(([, value]) => value); + expect(dotnetRootValues.length).toBeGreaterThan(0); + expect(dotnetRootValues.every((value) => value === 'C:\\selected')).toBe(true); + expect(dotnetRootX64Values.length).toBeGreaterThan(0); + expect(dotnetRootX64Values.every((value) => value === undefined)).toBe(true); + } finally { + if (ambientDotnetRoot === undefined) { + delete process.env.DOTNET_ROOT; + } else { + process.env.DOTNET_ROOT = ambientDotnetRoot; + } + existsSync.mockRestore(); + getExtensionPath.mockRestore(); + getCSharpDevKitMock.mockReset(); + getDotnetInfoMock.mockReset(); + } + }); + + test('removes all case-insensitive ambient aliases explicitly cleared by the selected workspace host', async () => { + const ambientDotnetRoot = process.env.DOTNET_ROOT; + const ambientDotnetRootX86 = process.env['DOTNET_ROOT(X86)']; + const existsSync = jest.spyOn(CoreClrDebugUtil, 'existsSync').mockReturnValue(true); + const getExtensionPath = jest.spyOn(common, 'getExtensionPath').mockReturnValue('C:\\extension'); + + try { + process.env.DOTNET_ROOT = 'C:\\ambient'; + process.env['DOTNET_ROOT(X86)'] = 'C:\\ambient-x86'; + getCSharpDevKitMock.mockReturnValue( + extensionWithHost(async () => ({ + status: 'ready', + dotnetPath: 'C:\\selected\\dotnet.exe', + environment: { + DOTNET_ROOT: null, + 'DOTNET_ROOT(x86)': null, + DOTNET_HOST_PATH: 'C:\\selected\\dotnet.exe', + }, + })) as unknown as vscode.Extension + ); + getDotnetInfoMock.mockResolvedValue({ + CliPath: 'C:\\selected\\dotnet.exe', + FullInfo: '', + Version: '10.0.100', + RuntimeId: 'win-x64', + Architecture: 'x64', + Runtimes: {}, + }); + + const factory = new DebugAdapterExecutableFactory( + new CoreClrDebugUtil('C:\\extension'), + new PlatformInformation('win32', 'x64'), + new EventStream(), + {}, + 'C:\\extension' + ); + const executable = (await factory.createDebugAdapterDescriptor( + { configuration: {} } as vscode.DebugSession, + undefined + )) as vscode.DebugAdapterExecutable; + + const mergedEnvironment = { ...process.env, ...executable.options?.env }; + const dotnetRootEntries = Object.entries(mergedEnvironment).filter( + ([key]) => key.toUpperCase() === 'DOTNET_ROOT' + ); + const dotnetRootX86Entries = Object.entries(mergedEnvironment).filter( + ([key]) => key.toUpperCase() === 'DOTNET_ROOT(X86)' + ); + expect(dotnetRootEntries.length).toBeGreaterThan(0); + expect(dotnetRootEntries.every(([, value]) => value === undefined)).toBe(true); + expect(dotnetRootX86Entries.length).toBeGreaterThan(0); + expect(dotnetRootX86Entries.every(([, value]) => value === undefined)).toBe(true); + expect(mergedEnvironment.DOTNET_HOST_PATH).toBe('C:\\selected\\dotnet.exe'); + } finally { + if (ambientDotnetRoot === undefined) { + delete process.env.DOTNET_ROOT; + } else { + process.env.DOTNET_ROOT = ambientDotnetRoot; + } + if (ambientDotnetRootX86 === undefined) { + delete process.env['DOTNET_ROOT(X86)']; + } else { + process.env['DOTNET_ROOT(X86)'] = ambientDotnetRootX86; + } + existsSync.mockRestore(); + getExtensionPath.mockRestore(); + getCSharpDevKitMock.mockReset(); + getDotnetInfoMock.mockReset(); + } + }); + + test('overrides every case-insensitive ambient alias with the selected workspace host value', async () => { + const ambientDotnetRootX86 = process.env['DOTNET_ROOT(X86)']; + const existsSync = jest.spyOn(CoreClrDebugUtil, 'existsSync').mockReturnValue(true); + const getExtensionPath = jest.spyOn(common, 'getExtensionPath').mockReturnValue('C:\\extension'); + + try { + process.env['DOTNET_ROOT(X86)'] = 'C:\\ambient-x86'; + getCSharpDevKitMock.mockReturnValue( + extensionWithHost(async () => ({ + status: 'ready', + dotnetPath: 'C:\\selected\\dotnet.exe', + environment: { + 'DOTNET_ROOT(x86)': 'C:\\selected-x86', + }, + })) as unknown as vscode.Extension + ); + getDotnetInfoMock.mockResolvedValue({ + CliPath: 'C:\\selected\\dotnet.exe', + FullInfo: '', + Version: '10.0.100', + RuntimeId: 'win-x64', + Architecture: 'x64', + Runtimes: {}, + }); + + const factory = new DebugAdapterExecutableFactory( + new CoreClrDebugUtil('C:\\extension'), + new PlatformInformation('win32', 'x64'), + new EventStream(), + {}, + 'C:\\extension' + ); + const executable = (await factory.createDebugAdapterDescriptor( + { configuration: {} } as vscode.DebugSession, + undefined + )) as vscode.DebugAdapterExecutable; + + const matchingValues = Object.entries({ ...process.env, ...executable.options?.env }) + .filter(([key]) => key.toUpperCase() === 'DOTNET_ROOT(X86)') + .map(([, value]) => value); + expect(matchingValues.length).toBeGreaterThan(0); + expect(matchingValues.every((value) => value === 'C:\\selected-x86')).toBe(true); + } finally { + if (ambientDotnetRootX86 === undefined) { + delete process.env['DOTNET_ROOT(X86)']; + } else { + process.env['DOTNET_ROOT(X86)'] = ambientDotnetRootX86; + } + existsSync.mockRestore(); + getExtensionPath.mockRestore(); + getCSharpDevKitMock.mockReset(); + getDotnetInfoMock.mockReset(); + } + }); +});