From b9333e11fcbf84e657fdf7e3e4d2587b2c23e9a5 Mon Sep 17 00:00:00 2001 From: Jake Radzikowski Date: Fri, 4 Sep 2026 15:32:06 -0700 Subject: [PATCH] Use C# Dev Kit workspace `dotnet` for debugger checks Feature-detect the optional C# Dev Kit workspace host export and defer only the debugger prerequisite decision. Preserve standalone discovery for absent, bypassed, old, or failed Dev Kit versions while suppressing competing remediation when Workspace Requirements is blocked. --- src/activateRoslyn.ts | 22 ++++-- src/coreclrDebug/activate.ts | 74 ++++++++++++++--- src/coreclrDebug/util.ts | 9 ++- src/coreclrDebug/workspaceDotnetHost.ts | 79 +++++++++++++++++++ src/csharpDevKitExports.ts | 11 +++ src/main.ts | 9 ++- src/shared/utils/getDotnetInfo.ts | 50 ++++++++++-- .../coreclrDebug/getDotnetInfo.test.ts | 48 +++++++++++ .../coreclrDebug/workspaceDotnetHost.test.ts | 77 ++++++++++++++++++ 9 files changed, 348 insertions(+), 31 deletions(-) create mode 100644 src/coreclrDebug/workspaceDotnetHost.ts create mode 100644 test/omnisharp/omnisharpUnitTests/coreclrDebug/getDotnetInfo.test.ts create mode 100644 test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts diff --git a/src/activateRoslyn.ts b/src/activateRoslyn.ts index a5f64d7fac..1b2098f94a 100644 --- a/src/activateRoslyn.ts +++ b/src/activateRoslyn.ts @@ -35,7 +35,10 @@ export function activateRoslyn( csharpChannel: vscode.LogOutputChannel, reporter: TelemetryReporter, csharpDevkitExtension: vscode.Extension | undefined, - getCoreClrDebugPromise: (languageServerStarted: Promise) => Promise + getCoreClrDebugPromise: ( + languageServerStarted: Promise, + csharpDevKitExports: Promise + ) => Promise ): CSharpExtensionExports { const roslynLanguageServerEvents = new RoslynLanguageServerEvents(); context.subscriptions.push(roslynLanguageServerEvents); @@ -62,8 +65,8 @@ export function activateRoslyn( ); debugSessionTracker.initializeDebugSessionHandlers(context); - tryGetCSharpDevKitExtensionExports(csharpDevkitExtension, observableCsharpChannel); - const coreClrDebugPromise = getCoreClrDebugPromise(roslynLanguageServerStartedPromise); + const csharpDevKitExports = tryGetCSharpDevKitExtensionExports(csharpDevkitExtension, observableCsharpChannel); + const coreClrDebugPromise = getCoreClrDebugPromise(roslynLanguageServerStartedPromise, csharpDevKitExports); const languageServerExport = new RoslynLanguageServerExport(roslynLanguageServerStartedPromise); const activeDocumentLanguageSupport = new ActiveDocumentLanguageSupportService( @@ -107,11 +110,15 @@ export function activateRoslyn( * This method will try to get the CSharpDevKitExports through a thenable promise, * awaiting `activate` will cause this extension's activation to hang. */ -function tryGetCSharpDevKitExtensionExports( +async function tryGetCSharpDevKitExtensionExports( csharpDevKit: vscode.Extension | undefined, csharpChannel: vscode.LogOutputChannel -): void { - csharpDevKit?.activate().then( +): Promise { + if (!csharpDevKit) { + return Promise.resolve(undefined); + } + + return Promise.resolve(csharpDevKit.activate()).then( async (exports: CSharpDevKitExports) => { if (exports && exports.serviceBroker) { // When proffering this IServiceBroker into our own container, @@ -131,9 +138,12 @@ function tryGetCSharpDevKitExtensionExports( } else { csharpChannel.error(`'${csharpDevkitExtensionId}' activated but did not return expected Exports.`); } + + return exports; }, () => { csharpChannel.error(`Failed to activate '${csharpDevkitExtensionId}'`); + return undefined; } ); } diff --git a/src/coreclrDebug/activate.ts b/src/coreclrDebug/activate.ts index 0101c4797a..2eabadceec 100644 --- a/src/coreclrDebug/activate.ts +++ b/src/coreclrDebug/activate.ts @@ -22,6 +22,8 @@ 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 } from '../csharpDevKitExports'; +import { resolveWorkspaceDotnetHost, WorkspaceDotnetHostResolution } from './workspaceDotnetHost'; export async function activate( thisExtension: vscode.Extension, @@ -29,11 +31,30 @@ export async function activate( platformInformation: PlatformInformation, eventStream: EventStream, csharpOutputChannel: vscode.OutputChannel, - languageServerStartedPromise: Promise | undefined + languageServerStartedPromise: Promise | undefined, + csharpDevKitExports: Promise | undefined ) { const disposables = new CompositeDisposable(); + let disposed = false; + context.subscriptions.push({ + dispose: () => { + disposed = true; + }, + }); const debugUtil = new CoreClrDebugUtil(context.extensionPath); + const workspaceDotnetHost = resolveWorkspaceDotnetHost(csharpDevKitExports); + let completeDebuggerInstallPromise: Promise | undefined; + const ensureDebuggerInstallComplete = async () => { + completeDebuggerInstallPromise ??= completeDebuggerInstall( + debugUtil, + platformInformation, + eventStream, + workspaceDotnetHost, + () => disposed + ); + return await completeDebuggerInstallPromise; + }; if (!CoreClrDebugUtil.existsSync(debugUtil.debugAdapterDir())) { const isValidArchitecture: boolean = await checkIsValidArchitecture(platformInformation, eventStream); @@ -48,7 +69,7 @@ export async function activate( showInstallErrorMessage(eventStream); } } else if (!CoreClrDebugUtil.existsSync(debugUtil.installCompleteFilePath())) { - await completeDebuggerInstall(debugUtil, platformInformation, eventStream); + await ensureDebuggerInstallComplete(); } // register process picker for attach for legacy configurations. @@ -97,11 +118,12 @@ export async function activate( ); const factory = new DebugAdapterExecutableFactory( - debugUtil, platformInformation, eventStream, thisExtension.packageJSON, - thisExtension.extensionPath + thisExtension.extensionPath, + ensureDebuggerInstallComplete, + workspaceDotnetHost ); /** 'clr' type does not have a intial configuration provider, but we need to register it to support the common debugger features listed in {@link BaseVsDbgConfigurationProvider} */ context.subscriptions.push( @@ -177,10 +199,24 @@ async function checkIsValidArchitecture( async function completeDebuggerInstall( debugUtil: CoreClrDebugUtil, platformInformation: PlatformInformation, - eventStream: EventStream + eventStream: EventStream, + workspaceDotnetHost: Promise, + isDisposed: () => boolean ): Promise { try { - await debugUtil.checkDotNetCli(omnisharpOptions.dotNetCliPaths); + const workspaceHost = await workspaceDotnetHost; + if (workspaceHost.kind === 'blocked') { + return false; + } + + if (workspaceHost.kind === '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()); @@ -201,8 +237,10 @@ async function completeDebuggerInstall( const error = err as Error; // Check for dotnet tools failed. pop the UI - showDotnetToolsWarning(error.message); - eventStream.post(new DebuggerPrerequisiteWarning(error.message)); + if (!isDisposed()) { + showDotnetToolsWarning(error.message); + eventStream.post(new DebuggerPrerequisiteWarning(error.message)); + } // TODO: log telemetry? return false; } @@ -257,11 +295,12 @@ function showDotnetToolsWarning(message: string): void { // Else it will launch the debug adapter export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescriptorFactory { constructor( - private readonly debugUtil: CoreClrDebugUtil, private readonly platformInfo: PlatformInformation, private readonly eventStream: EventStream, private readonly packageJSON: any, - private readonly extensionPath: string + private readonly extensionPath: string, + private readonly ensureDebuggerInstallComplete: () => Promise, + private readonly workspaceDotnetHost: Promise ) {} async createDebugAdapterDescriptor( @@ -301,7 +340,7 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip } // install.complete does not exist, check dotnetCLI to see if we can complete. else if (!CoreClrDebugUtil.existsSync(util.installCompleteFilePath())) { - const success = await completeDebuggerInstall(this.debugUtil, this.platformInfo, this.eventStream); + const success = await this.ensureDebuggerInstallComplete(); if (!success) { this.eventStream.post(new DebuggerNotInstalledFailure()); throw new Error( @@ -317,7 +356,14 @@ 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 this.workspaceDotnetHost; + const dotNetInfo = + workspaceHost.kind === 'ready' + ? await getDotnetInfo([], { + dotnetExecutablePath: workspaceHost.dotnetPath, + environment: workspaceHost.environment, + }) + : await getDotnetInfo(omnisharpOptions.dotNetCliPaths); const targetArchitecture = getTargetArchitecture( this.platformInfo, _session.configuration.targetArchitecture, @@ -332,7 +378,9 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip // 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) : ''); + (workspaceHost.kind === 'ready' && workspaceHost.environment?.DOTNET_ROOT) || + process.env.DOTNET_ROOT || + (dotNetInfo.CliPath ? path.dirname(dotNetInfo.CliPath) : ''); let options: vscode.DebugAdapterExecutableOptions | undefined = undefined; if (dotnetRoot) { 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/coreclrDebug/workspaceDotnetHost.ts b/src/coreclrDebug/workspaceDotnetHost.ts new file mode 100644 index 0000000000..f2ffdfe089 --- /dev/null +++ b/src/coreclrDebug/workspaceDotnetHost.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CSharpDevKitExports, WorkspaceDotnetHost } from '../csharpDevKitExports'; + +export type WorkspaceDotnetHostResolution = + | { kind: 'standalone' } + | { kind: 'blocked' } + | { + kind: 'ready'; + dotnetPath: string; + environment?: Readonly>; + }; + +const DEV_KIT_ACTIVATION_TIMEOUT_MS = 90_000; +const timedOut = Symbol('timedOut'); + +/** + * Waits for the already-started C# Dev Kit activation without participating in extension activation. + * Older Dev Kit versions and failed or bounded-out activation preserve standalone C# behavior. + */ +export async function resolveWorkspaceDotnetHost( + devKitExports: Promise | undefined, + timeoutMs = DEV_KIT_ACTIVATION_TIMEOUT_MS +): Promise { + if (!devKitExports) { + return { kind: 'standalone' }; + } + + const exports = await settleWithin(devKitExports, timeoutMs); + if (exports === timedOut) { + // Dev Kit is installed and still activating. Do not race its Workspace Requirements remediation. + return { kind: 'blocked' }; + } + if (!exports || typeof exports.getWorkspaceDotnetHost !== 'function') { + return { kind: 'standalone' }; + } + + const host = await settleWithin(exports.getWorkspaceDotnetHost(), timeoutMs); + if (host === timedOut) { + return { kind: 'blocked' }; + } + return mapWorkspaceDotnetHost(host); +} + +function mapWorkspaceDotnetHost(host: WorkspaceDotnetHost | undefined): WorkspaceDotnetHostResolution { + if (!host || host.status === 'not-applicable') { + return { kind: 'standalone' }; + } + if (host.status === 'blocked') { + return { kind: 'blocked' }; + } + if (!host.dotnetPath) { + return { kind: 'standalone' }; + } + return { + kind: 'ready', + dotnetPath: host.dotnetPath, + environment: host.environment, + }; +} + +async function settleWithin(promise: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise.catch(() => undefined), + new Promise((resolve) => { + timer = setTimeout(() => resolve(timedOut), timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} diff --git a/src/csharpDevKitExports.ts b/src/csharpDevKitExports.ts index 696b43c650..5fa46ef2cf 100644 --- a/src/csharpDevKitExports.ts +++ b/src/csharpDevKitExports.ts @@ -7,6 +7,15 @@ import * as vscode from 'vscode'; import { IServiceBroker } from '@microsoft/servicehub-framework'; +export type WorkspaceDotnetHost = + | { + status: 'ready'; + dotnetPath: string; + environment?: Readonly>; + } + | { status: 'blocked' } + | { status: 'not-applicable' }; + export interface CSharpDevKitExports { serviceBroker: IServiceBroker; getBrokeredServiceServerPipeName: () => Promise; @@ -14,4 +23,6 @@ export interface CSharpDevKitExports { hasServerProcessLoaded: () => boolean; serverProcessLoaded: vscode.Event; setupTelemetryEnvironmentAsync: (env: NodeJS.ProcessEnv) => Promise; + /** Gets the immutable dotnet host selected for this workspace by C# Dev Kit. */ + getWorkspaceDotnetHost?: () => Promise; } diff --git a/src/main.ts b/src/main.ts index 600619a2f5..3f5b0f0718 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,7 @@ import { checkDotNetRuntimeExtensionVersion } from './checkDotNetRuntimeExtensio import { checkIsSupportedPlatform } from './checkSupportedPlatform'; import { activateRoslyn } from './activateRoslyn'; import { LimitedActivationStatus } from './shared/limitedActivationStatus'; +import { CSharpDevKitExports } from './csharpDevKitExports'; export async function activate( context: vscode.ExtensionContext @@ -121,7 +122,10 @@ export async function activate( }) ); } else { - const getCoreClrDebugPromise = async (languageServerStartedPromise: Promise) => { + const getCoreClrDebugPromise = async ( + languageServerStartedPromise: Promise, + csharpDevKitExports?: Promise + ) => { let coreClrDebugPromise = Promise.resolve(); if (runtimeDependenciesExist['Debugger']) { // activate coreclr-debug @@ -131,7 +135,8 @@ export async function activate( platformInfo, eventStream, csharpChannel, - languageServerStartedPromise + languageServerStartedPromise, + csharpDevKitExports ); } 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..5af42d3518 --- /dev/null +++ b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CSharpDevKitExports, WorkspaceDotnetHost } from '../../../../src/csharpDevKitExports'; +import { resolveWorkspaceDotnetHost } from '../../../../src/coreclrDebug/workspaceDotnetHost'; + +function exportsWithHost(getWorkspaceDotnetHost?: () => Promise): CSharpDevKitExports { + return { getWorkspaceDotnetHost } as unknown as CSharpDevKitExports; +} + +describe('resolveWorkspaceDotnetHost', () => { + test('uses standalone behavior when C# Dev Kit is absent', async () => { + await expect(resolveWorkspaceDotnetHost(undefined)).resolves.toEqual({ kind: 'standalone' }); + }); + + test('uses standalone behavior with old C# Dev Kit exports', async () => { + await expect(resolveWorkspaceDotnetHost(Promise.resolve(exportsWithHost()))).resolves.toEqual({ + kind: 'standalone', + }); + }); + + test('suppresses standalone probing when Workspace Requirements is blocked', async () => { + await expect( + resolveWorkspaceDotnetHost(Promise.resolve(exportsWithHost(async () => ({ status: 'blocked' })))) + ).resolves.toEqual({ kind: 'blocked' }); + }); + + test('returns the exact selected managed host and environment', async () => { + const environment = { DOTNET_ROOT: '/managed', PATH: '/managed' }; + + await expect( + resolveWorkspaceDotnetHost( + Promise.resolve( + exportsWithHost(async () => ({ + status: 'ready', + dotnetPath: '/managed/dotnet', + environment, + })) + ) + ) + ).resolves.toEqual({ kind: 'ready', dotnetPath: '/managed/dotnet', environment }); + }); + + test('uses standalone behavior when Workspace Requirements is not applicable', async () => { + await expect( + resolveWorkspaceDotnetHost(Promise.resolve(exportsWithHost(async () => ({ status: 'not-applicable' })))) + ).resolves.toEqual({ kind: 'standalone' }); + }); + + test('falls back when C# Dev Kit activation rejects without an unhandled rejection', async () => { + await expect(resolveWorkspaceDotnetHost(Promise.reject(new Error('activation failed')))).resolves.toEqual({ + kind: 'standalone', + }); + }); + + test('falls back when the optional export rejects', async () => { + await expect( + resolveWorkspaceDotnetHost( + Promise.resolve(exportsWithHost(async () => Promise.reject(new Error('selection unavailable')))) + ) + ).resolves.toEqual({ kind: 'standalone' }); + }); + + test('bounds an activation that never settles without racing Dev Kit remediation', async () => { + jest.useFakeTimers(); + try { + const result = resolveWorkspaceDotnetHost(new Promise(() => {}), 100); + await jest.advanceTimersByTimeAsync(100); + await expect(result).resolves.toEqual({ kind: 'blocked' }); + } finally { + jest.useRealTimers(); + } + }); +});