From b9333e11fcbf84e657fdf7e3e4d2587b2c23e9a5 Mon Sep 17 00:00:00 2001 From: Jake Radzikowski Date: Fri, 4 Sep 2026 15:32:06 -0700 Subject: [PATCH 1/7] 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(); + } + }); +}); From 25d0d68771412ec4c65255011cdb8681a5228022 Mon Sep 17 00:00:00 2001 From: Jake Date: Thu, 10 Sep 2026 23:06:11 -0700 Subject: [PATCH 2/7] Use C# Dev Kit workspace dotnet service Converge debugger prerequisite checks on the optional workspace dotnet service contract while preserving standalone compatibility. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/activateRoslyn.ts | 22 +--- src/coreclrDebug/activate.ts | 103 ++++++++++-------- src/coreclrDebug/workspaceDotnetHost.ts | 79 -------------- src/csharpDevKitExports.ts | 32 ++++-- src/main.ts | 9 +- .../coreclrDebug/workspaceDotnetHost.test.ts | 69 +++++++----- 6 files changed, 132 insertions(+), 182 deletions(-) delete mode 100644 src/coreclrDebug/workspaceDotnetHost.ts diff --git a/src/activateRoslyn.ts b/src/activateRoslyn.ts index 1b2098f94a..a5f64d7fac 100644 --- a/src/activateRoslyn.ts +++ b/src/activateRoslyn.ts @@ -35,10 +35,7 @@ export function activateRoslyn( csharpChannel: vscode.LogOutputChannel, reporter: TelemetryReporter, csharpDevkitExtension: vscode.Extension | undefined, - getCoreClrDebugPromise: ( - languageServerStarted: Promise, - csharpDevKitExports: Promise - ) => Promise + getCoreClrDebugPromise: (languageServerStarted: Promise) => Promise ): CSharpExtensionExports { const roslynLanguageServerEvents = new RoslynLanguageServerEvents(); context.subscriptions.push(roslynLanguageServerEvents); @@ -65,8 +62,8 @@ export function activateRoslyn( ); debugSessionTracker.initializeDebugSessionHandlers(context); - const csharpDevKitExports = tryGetCSharpDevKitExtensionExports(csharpDevkitExtension, observableCsharpChannel); - const coreClrDebugPromise = getCoreClrDebugPromise(roslynLanguageServerStartedPromise, csharpDevKitExports); + tryGetCSharpDevKitExtensionExports(csharpDevkitExtension, observableCsharpChannel); + const coreClrDebugPromise = getCoreClrDebugPromise(roslynLanguageServerStartedPromise); const languageServerExport = new RoslynLanguageServerExport(roslynLanguageServerStartedPromise); const activeDocumentLanguageSupport = new ActiveDocumentLanguageSupportService( @@ -110,15 +107,11 @@ 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. */ -async function tryGetCSharpDevKitExtensionExports( +function tryGetCSharpDevKitExtensionExports( csharpDevKit: vscode.Extension | undefined, csharpChannel: vscode.LogOutputChannel -): Promise { - if (!csharpDevKit) { - return Promise.resolve(undefined); - } - - return Promise.resolve(csharpDevKit.activate()).then( +): void { + csharpDevKit?.activate().then( async (exports: CSharpDevKitExports) => { if (exports && exports.serviceBroker) { // When proffering this IServiceBroker into our own container, @@ -138,12 +131,9 @@ async 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 2eabadceec..211f8b7c54 100644 --- a/src/coreclrDebug/activate.ts +++ b/src/coreclrDebug/activate.ts @@ -22,8 +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 } from '../csharpDevKitExports'; -import { resolveWorkspaceDotnetHost, WorkspaceDotnetHostResolution } from './workspaceDotnetHost'; +import { CSharpDevKitExports, WorkspaceDotnetHost } from '../csharpDevKitExports'; export async function activate( thisExtension: vscode.Extension, @@ -31,30 +30,11 @@ export async function activate( platformInformation: PlatformInformation, eventStream: EventStream, csharpOutputChannel: vscode.OutputChannel, - languageServerStartedPromise: Promise | undefined, - csharpDevKitExports: Promise | undefined + languageServerStartedPromise: 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); @@ -69,7 +49,7 @@ export async function activate( showInstallErrorMessage(eventStream); } } else if (!CoreClrDebugUtil.existsSync(debugUtil.installCompleteFilePath())) { - await ensureDebuggerInstallComplete(); + await completeDebuggerInstall(debugUtil, platformInformation, eventStream); } // register process picker for attach for legacy configurations. @@ -118,12 +98,11 @@ export async function activate( ); const factory = new DebugAdapterExecutableFactory( + debugUtil, platformInformation, eventStream, thisExtension.packageJSON, - thisExtension.extensionPath, - ensureDebuggerInstallComplete, - workspaceDotnetHost + thisExtension.extensionPath ); /** '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( @@ -199,17 +178,15 @@ async function checkIsValidArchitecture( async function completeDebuggerInstall( debugUtil: CoreClrDebugUtil, platformInformation: PlatformInformation, - eventStream: EventStream, - workspaceDotnetHost: Promise, - isDisposed: () => boolean + eventStream: EventStream ): Promise { try { - const workspaceHost = await workspaceDotnetHost; - if (workspaceHost.kind === 'blocked') { + const workspaceHost = await resolveWorkspaceDotnetHost(); + if (workspaceHost?.status === 'blocked') { return false; } - if (workspaceHost.kind === 'ready') { + if (workspaceHost?.status === 'ready') { await debugUtil.checkDotNetCli([], { dotnetExecutablePath: workspaceHost.dotnetPath, environment: workspaceHost.environment, @@ -237,15 +214,45 @@ async function completeDebuggerInstall( const error = err as Error; // Check for dotnet tools failed. pop the UI - if (!isDisposed()) { - showDotnetToolsWarning(error.message); - eventStream.post(new DebuggerPrerequisiteWarning(error.message)); - } + showDotnetToolsWarning(error.message); + eventStream.post(new DebuggerPrerequisiteWarning(error.message)); // TODO: log telemetry? return false; } } +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 timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + Promise.resolve() + .then(async () => await csharpDevKit.activate()) + .then(async (exports) => await exports?.dotnet?.getWorkspaceDotnetHost?.()) + .catch(() => undefined), + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), timeoutMs); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + function showInstallErrorMessage(eventStream: EventStream) { eventStream.post(new DebuggerNotInstalledFailure()); showErrorMessage( @@ -295,12 +302,11 @@ 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 ensureDebuggerInstallComplete: () => Promise, - private readonly workspaceDotnetHost: Promise + private readonly extensionPath: string ) {} async createDebugAdapterDescriptor( @@ -340,7 +346,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 this.ensureDebuggerInstallComplete(); + const success = await completeDebuggerInstall(this.debugUtil, this.platformInfo, this.eventStream); if (!success) { this.eventStream.post(new DebuggerNotInstalledFailure()); throw new Error( @@ -356,9 +362,18 @@ 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 workspaceHost = await this.workspaceDotnetHost; + 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.kind === 'ready' + workspaceHost?.status === 'ready' ? await getDotnetInfo([], { dotnetExecutablePath: workspaceHost.dotnetPath, environment: workspaceHost.environment, @@ -378,9 +393,7 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip // Look to see if DOTNET_ROOT is set, then use dotnet cli path const dotnetRoot: string = - (workspaceHost.kind === 'ready' && workspaceHost.environment?.DOTNET_ROOT) || - process.env.DOTNET_ROOT || - (dotNetInfo.CliPath ? path.dirname(dotNetInfo.CliPath) : ''); + process.env.DOTNET_ROOT ?? (dotNetInfo.CliPath ? path.dirname(dotNetInfo.CliPath) : ''); let options: vscode.DebugAdapterExecutableOptions | undefined = undefined; if (dotnetRoot) { diff --git a/src/coreclrDebug/workspaceDotnetHost.ts b/src/coreclrDebug/workspaceDotnetHost.ts deleted file mode 100644 index f2ffdfe089..0000000000 --- a/src/coreclrDebug/workspaceDotnetHost.ts +++ /dev/null @@ -1,79 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 5fa46ef2cf..d9b5d163e4 100644 --- a/src/csharpDevKitExports.ts +++ b/src/csharpDevKitExports.ts @@ -9,12 +9,30 @@ import { IServiceBroker } from '@microsoft/servicehub-framework'; export type WorkspaceDotnetHost = | { - status: 'ready'; - dotnetPath: string; - environment?: Readonly>; + readonly status: 'ready'; + readonly dotnetPath: string; + readonly environment: Readonly>; } - | { status: 'blocked' } - | { status: 'not-applicable' }; + | { readonly status: 'blocked' } + | { readonly status: 'not-applicable' }; + +export interface WorkspaceSdkInfo { + readonly executablePath: string; + readonly sdkPath: string; + readonly sdkVersion: string; + readonly architecture: string; + readonly environment: Readonly>; +} + +export interface WorkspaceDotnetService { + 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. */ + getWorkspaceDotnetHost?(): Promise; +} export interface CSharpDevKitExports { serviceBroker: IServiceBroker; @@ -23,6 +41,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; + /** The authoritative .NET SDK selected for this workspace. */ + dotnet?: WorkspaceDotnetService; } diff --git a/src/main.ts b/src/main.ts index 3f5b0f0718..600619a2f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,7 +27,6 @@ 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 @@ -122,10 +121,7 @@ export async function activate( }) ); } else { - const getCoreClrDebugPromise = async ( - languageServerStartedPromise: Promise, - csharpDevKitExports?: Promise - ) => { + const getCoreClrDebugPromise = async (languageServerStartedPromise: Promise) => { let coreClrDebugPromise = Promise.resolve(); if (runtimeDependenciesExist['Debugger']) { // activate coreclr-debug @@ -135,8 +131,7 @@ export async function activate( platformInfo, eventStream, csharpChannel, - languageServerStartedPromise, - csharpDevKitExports + languageServerStartedPromise ); } diff --git a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts index 5af42d3518..3e1feb24eb 100644 --- a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts +++ b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts @@ -4,28 +4,41 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, jest, test } from '@jest/globals'; -import { CSharpDevKitExports, WorkspaceDotnetHost } from '../../../../src/csharpDevKitExports'; -import { resolveWorkspaceDotnetHost } from '../../../../src/coreclrDebug/workspaceDotnetHost'; +import * as vscode from 'vscode'; +import { WorkspaceDotnetHost, WorkspaceDotnetService, WorkspaceSdkInfo } from '../../../../src/csharpDevKitExports'; +import { resolveWorkspaceDotnetHost } from '../../../../src/coreclrDebug/activate'; -function exportsWithHost(getWorkspaceDotnetHost?: () => Promise): CSharpDevKitExports { - return { getWorkspaceDotnetHost } as unknown as CSharpDevKitExports; +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('resolveWorkspaceDotnetHost', () => { test('uses standalone behavior when C# Dev Kit is absent', async () => { - await expect(resolveWorkspaceDotnetHost(undefined)).resolves.toEqual({ kind: 'standalone' }); + await expect(resolveWorkspaceDotnetHost(null)).resolves.toBeUndefined(); }); test('uses standalone behavior with old C# Dev Kit exports', async () => { - await expect(resolveWorkspaceDotnetHost(Promise.resolve(exportsWithHost()))).resolves.toEqual({ - kind: 'standalone', - }); + 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(Promise.resolve(exportsWithHost(async () => ({ status: 'blocked' })))) - ).resolves.toEqual({ kind: 'blocked' }); + resolveWorkspaceDotnetHost(extensionWithHost(async () => ({ status: 'blocked' }))) + ).resolves.toEqual({ status: 'blocked' }); }); test('returns the exact selected managed host and environment', async () => { @@ -33,43 +46,43 @@ describe('resolveWorkspaceDotnetHost', () => { await expect( resolveWorkspaceDotnetHost( - Promise.resolve( - exportsWithHost(async () => ({ - status: 'ready', - dotnetPath: '/managed/dotnet', - environment, - })) - ) + extensionWithHost(async () => ({ + status: 'ready', + dotnetPath: '/managed/dotnet', + environment, + })) ) - ).resolves.toEqual({ kind: '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(Promise.resolve(exportsWithHost(async () => ({ status: 'not-applicable' })))) - ).resolves.toEqual({ kind: 'standalone' }); + 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(Promise.reject(new Error('activation failed')))).resolves.toEqual({ - kind: 'standalone', - }); + 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( - Promise.resolve(exportsWithHost(async () => Promise.reject(new Error('selection unavailable')))) + extensionWithHost(async () => Promise.reject(new Error('selection unavailable'))) ) - ).resolves.toEqual({ kind: 'standalone' }); + ).resolves.toBeUndefined(); }); - test('bounds an activation that never settles without racing Dev Kit remediation', async () => { + test('bounds an activation that never settles and falls back', async () => { jest.useFakeTimers(); try { - const result = resolveWorkspaceDotnetHost(new Promise(() => {}), 100); + const result = resolveWorkspaceDotnetHost({ activate: async () => new Promise(() => {}) }, 100); await jest.advanceTimersByTimeAsync(100); - await expect(result).resolves.toEqual({ kind: 'blocked' }); + await expect(result).resolves.toBeUndefined(); } finally { jest.useRealTimers(); } From b0d9ef93b529141e4bfe2d190f0738f3f84f804c Mon Sep 17 00:00:00 2001 From: Jake Date: Thu, 10 Sep 2026 23:21:36 -0700 Subject: [PATCH 3/7] Guard late Dev Kit activation Prevent a Dev Kit activation that finishes after the bounded fallback from starting workspace host resolution and competing remediation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/coreclrDebug/activate.ts | 13 +++++++++-- .../coreclrDebug/workspaceDotnetHost.test.ts | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/coreclrDebug/activate.ts b/src/coreclrDebug/activate.ts index 211f8b7c54..80dccd99cd 100644 --- a/src/coreclrDebug/activate.ts +++ b/src/coreclrDebug/activate.ts @@ -235,15 +235,24 @@ export async function resolveWorkspaceDotnetHost( 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) => await exports?.dotnet?.getWorkspaceDotnetHost?.()) + .then(async (exports) => { + if (timedOut) { + return undefined; + } + return await exports?.dotnet?.getWorkspaceDotnetHost?.(); + }) .catch(() => undefined), new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), timeoutMs); + timer = setTimeout(() => { + timedOut = true; + resolve(undefined); + }, timeoutMs); }), ]); } finally { diff --git a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts index 3e1feb24eb..b986a23409 100644 --- a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts +++ b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts @@ -87,4 +87,26 @@ describe('resolveWorkspaceDotnetHost', () => { 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(); + } + }); }); From 7b63a0c9980d10a81e40e968489615a5a4b632e4 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 11 Sep 2026 08:41:48 -0700 Subject: [PATCH 4/7] Use workspace dotnet environment for debugger launch Forward the selected workspace host environment to vsdbg-ui and prevent an ambient DOTNET_ROOT from overriding the selected host. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/coreclrDebug/activate.ts | 21 +++- .../coreclrDebug/workspaceDotnetHost.test.ts | 101 +++++++++++++++++- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/src/coreclrDebug/activate.ts b/src/coreclrDebug/activate.ts index 80dccd99cd..a443005d8a 100644 --- a/src/coreclrDebug/activate.ts +++ b/src/coreclrDebug/activate.ts @@ -400,15 +400,26 @@ 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) : ''); + const workspaceEnvironment = + workspaceHost?.status === 'ready' + ? Object.fromEntries( + Object.entries(workspaceHost.environment).filter( + (entry): entry is [string, string] => entry[1] !== null + ) + ) + : undefined; + const dotnetRoot = + workspaceEnvironment?.DOTNET_ROOT ?? + (workspaceHost?.status === 'ready' + ? path.dirname(workspaceHost.dotnetPath) + : (process.env.DOTNET_ROOT ?? (dotNetInfo.CliPath ? path.dirname(dotNetInfo.CliPath) : ''))); let options: vscode.DebugAdapterExecutableOptions | undefined = undefined; - if (dotnetRoot) { + if (workspaceEnvironment || dotnetRoot) { options = { env: { - DOTNET_ROOT: dotnetRoot, + ...workspaceEnvironment, + ...(dotnetRoot && { DOTNET_ROOT: dotnetRoot }), }, }; } diff --git a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts index b986a23409..864aeed13c 100644 --- a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts +++ b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts @@ -5,8 +5,42 @@ import { describe, expect, jest, test } from '@jest/globals'; import * as vscode from 'vscode'; -import { WorkspaceDotnetHost, WorkspaceDotnetService, WorkspaceSdkInfo } from '../../../../src/csharpDevKitExports'; -import { resolveWorkspaceDotnetHost } from '../../../../src/coreclrDebug/activate'; +import * as common from '../../../../src/common'; +import { + CSharpDevKitExports, + WorkspaceDotnetHost, + WorkspaceDotnetService, + WorkspaceSdkInfo, +} from '../../../../src/csharpDevKitExports'; +import { DebugAdapterExecutableFactory, resolveWorkspaceDotnetHost } from '../../../../src/coreclrDebug/activate'; +import { CoreClrDebugUtil } from '../../../../src/coreclrDebug/util'; +import { EventStream } from '../../../../src/eventStream'; +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(); @@ -110,3 +144,66 @@ describe('resolveWorkspaceDotnetHost', () => { } }); }); + +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).toEqual({ + DOTNET_ROOT: 'C:\\selected', + DOTNET_HOST_PATH: 'C:\\selected\\dotnet.exe', + DOTNET_MULTILEVEL_LOOKUP: '0', + PATH: 'C:\\selected;C:\\Windows', + }); + } finally { + if (ambientDotnetRoot === undefined) { + delete process.env.DOTNET_ROOT; + } else { + process.env.DOTNET_ROOT = ambientDotnetRoot; + } + existsSync.mockRestore(); + getExtensionPath.mockRestore(); + getCSharpDevKitMock.mockReset(); + getDotnetInfoMock.mockReset(); + } + }); +}); From cf3dfdd90fdef28885317c96478334bc60c00d37 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 11 Sep 2026 12:50:18 -0700 Subject: [PATCH 5/7] Honor workspace dotnet environment removals Preserve null producer overrides through VS Code's merged debug adapter environment and document the version 0.1 contract semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/coreclrDebug/activate.ts | 44 +++++++------ src/csharpDevKitExports.ts | 19 +++++- .../coreclrDebug/workspaceDotnetHost.test.ts | 62 +++++++++++++++++++ 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/src/coreclrDebug/activate.ts b/src/coreclrDebug/activate.ts index a443005d8a..e7d14f73a3 100644 --- a/src/coreclrDebug/activate.ts +++ b/src/coreclrDebug/activate.ts @@ -400,28 +400,28 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip 'vsdbg-ui' + CoreClrDebugUtil.getPlatformExeExtension() ); - const workspaceEnvironment = - workspaceHost?.status === 'ready' - ? Object.fromEntries( - Object.entries(workspaceHost.environment).filter( - (entry): entry is [string, string] => entry[1] !== null - ) - ) - : undefined; - const dotnetRoot = - workspaceEnvironment?.DOTNET_ROOT ?? - (workspaceHost?.status === 'ready' - ? path.dirname(workspaceHost.dotnetPath) - : (process.env.DOTNET_ROOT ?? (dotNetInfo.CliPath ? path.dirname(dotNetInfo.CliPath) : ''))); - let options: vscode.DebugAdapterExecutableOptions | undefined = undefined; - if (workspaceEnvironment || dotnetRoot) { + if (workspaceHost?.status === 'ready') { + const workspaceEnvironment = Object.fromEntries( + Object.entries(workspaceHost.environment).map(([key, value]) => [key, value ?? undefined]) + ); + if (!hasEnvironmentVariable(workspaceHost.environment, 'DOTNET_ROOT')) { + workspaceEnvironment.DOTNET_ROOT = path.dirname(workspaceHost.dotnetPath); + } + options = { - env: { - ...workspaceEnvironment, - ...(dotnetRoot && { 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); @@ -431,3 +431,9 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip return executable; } } + +function hasEnvironmentVariable(environment: Readonly>, name: string): boolean { + return Object.keys(environment).some((key) => + process.platform === 'win32' ? key.toUpperCase() === name.toUpperCase() : key === name + ); +} diff --git a/src/csharpDevKitExports.ts b/src/csharpDevKitExports.ts index d9b5d163e4..c74766a527 100644 --- a/src/csharpDevKitExports.ts +++ b/src/csharpDevKitExports.ts @@ -7,11 +7,17 @@ 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: Readonly>; + readonly environment: WorkspaceDotnetEnvironment; } | { readonly status: 'blocked' } | { readonly status: 'not-applicable' }; @@ -21,16 +27,23 @@ export interface WorkspaceSdkInfo { readonly sdkPath: string; readonly sdkVersion: string; readonly architecture: string; - readonly environment: Readonly>; + 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. */ + /** + * 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; } diff --git a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts index 864aeed13c..1673922ca4 100644 --- a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts +++ b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts @@ -206,4 +206,66 @@ describe('DebugAdapterExecutableFactory', () => { getDotnetInfoMock.mockReset(); } }); + + test('removes ambient environment variables explicitly cleared by the selected workspace host', async () => { + const ambientDotnetRoot = process.env.DOTNET_ROOT; + const ambientDotnetRootX64 = process.env.DOTNET_ROOT_X64; + 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_X64 = 'C:\\ambient-x64'; + getCSharpDevKitMock.mockReturnValue( + extensionWithHost(async () => ({ + status: 'ready', + dotnetPath: 'C:\\selected\\dotnet.exe', + environment: { + DOTNET_ROOT: null, + DOTNET_ROOT_X64: 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; + + expect(executable.options?.env).toHaveProperty('DOTNET_ROOT', undefined); + expect(executable.options?.env).toHaveProperty('DOTNET_ROOT_X64', undefined); + expect(executable.options?.env).toHaveProperty('DOTNET_HOST_PATH', 'C:\\selected\\dotnet.exe'); + } finally { + if (ambientDotnetRoot === undefined) { + delete process.env.DOTNET_ROOT; + } else { + process.env.DOTNET_ROOT = ambientDotnetRoot; + } + if (ambientDotnetRootX64 === undefined) { + delete process.env.DOTNET_ROOT_X64; + } else { + process.env.DOTNET_ROOT_X64 = ambientDotnetRootX64; + } + existsSync.mockRestore(); + getExtensionPath.mockRestore(); + getCSharpDevKitMock.mockReset(); + getDotnetInfoMock.mockReset(); + } + }); }); From a1690a8b1e17bbc4f22d8b9a600e5500fdaf78b2 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 11 Sep 2026 13:37:35 -0700 Subject: [PATCH 6/7] Normalize workspace dotnet environment aliases Neutralize every case-insensitive Windows ambient spelling before VS Code merges debug adapter overrides, and cover install-state branches directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/coreclrDebug/activate.ts | 47 +++-- .../coreclrDebug/workspaceDotnetHost.test.ts | 168 ++++++++++++++++-- 2 files changed, 194 insertions(+), 21 deletions(-) diff --git a/src/coreclrDebug/activate.ts b/src/coreclrDebug/activate.ts index e7d14f73a3..59c904f003 100644 --- a/src/coreclrDebug/activate.ts +++ b/src/coreclrDebug/activate.ts @@ -175,7 +175,7 @@ async function checkIsValidArchitecture( return false; } -async function completeDebuggerInstall( +export async function completeDebuggerInstall( debugUtil: CoreClrDebugUtil, platformInformation: PlatformInformation, eventStream: EventStream @@ -402,12 +402,11 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip let options: vscode.DebugAdapterExecutableOptions | undefined = undefined; if (workspaceHost?.status === 'ready') { - const workspaceEnvironment = Object.fromEntries( - Object.entries(workspaceHost.environment).map(([key, value]) => [key, value ?? undefined]) + const workspaceEnvironment = createDebugAdapterEnvironment( + workspaceHost.environment, + workspaceHost.dotnetPath, + this.platformInfo.isWindows() ); - if (!hasEnvironmentVariable(workspaceHost.environment, 'DOTNET_ROOT')) { - workspaceEnvironment.DOTNET_ROOT = path.dirname(workspaceHost.dotnetPath); - } options = { // VS Code merges this object with its environment before spawning. Undefined values survive that @@ -432,8 +431,36 @@ export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescrip } } -function hasEnvironmentVariable(environment: Readonly>, name: string): boolean { - return Object.keys(environment).some((key) => - process.platform === 'win32' ? key.toUpperCase() === name.toUpperCase() : key === name - ); +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/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts index 1673922ca4..f9d1dc6781 100644 --- a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts +++ b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts @@ -12,9 +12,14 @@ import { WorkspaceDotnetService, WorkspaceSdkInfo, } from '../../../../src/csharpDevKitExports'; -import { DebugAdapterExecutableFactory, resolveWorkspaceDotnetHost } from '../../../../src/coreclrDebug/activate'; +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'; @@ -56,6 +61,83 @@ function extensionWithHost(getWorkspaceDotnetHost?: () => Promise { + 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(); @@ -207,22 +289,22 @@ describe('DebugAdapterExecutableFactory', () => { } }); - test('removes ambient environment variables explicitly cleared by the selected workspace host', async () => { + test('removes all case-insensitive ambient aliases explicitly cleared by the selected workspace host', async () => { const ambientDotnetRoot = process.env.DOTNET_ROOT; - const ambientDotnetRootX64 = process.env.DOTNET_ROOT_X64; + 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_X64 = 'C:\\ambient-x64'; + 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_X64: null, + 'DOTNET_ROOT(x86)': null, DOTNET_HOST_PATH: 'C:\\selected\\dotnet.exe', }, })) as unknown as vscode.Extension @@ -248,19 +330,83 @@ describe('DebugAdapterExecutableFactory', () => { undefined )) as vscode.DebugAdapterExecutable; - expect(executable.options?.env).toHaveProperty('DOTNET_ROOT', undefined); - expect(executable.options?.env).toHaveProperty('DOTNET_ROOT_X64', undefined); - expect(executable.options?.env).toHaveProperty('DOTNET_HOST_PATH', 'C:\\selected\\dotnet.exe'); + 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 (ambientDotnetRootX64 === undefined) { - delete process.env.DOTNET_ROOT_X64; + 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_X64 = ambientDotnetRootX64; + process.env['DOTNET_ROOT(X86)'] = ambientDotnetRootX86; } existsSync.mockRestore(); getExtensionPath.mockRestore(); From 3dfe5b09eede057ef2aa852b8cbccce138929d79 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 11 Sep 2026 13:55:44 -0700 Subject: [PATCH 7/7] Fix workspace dotnet environment assertions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../coreclrDebug/workspaceDotnetHost.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts index f9d1dc6781..3de58b18f6 100644 --- a/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts +++ b/test/omnisharp/omnisharpUnitTests/coreclrDebug/workspaceDotnetHost.test.ts @@ -270,12 +270,22 @@ describe('DebugAdapterExecutableFactory', () => { undefined )) as vscode.DebugAdapterExecutable; - expect(executable.options?.env).toEqual({ + 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;