diff --git a/package.json b/package.json index 2452802d3..3f194ee28 100644 --- a/package.json +++ b/package.json @@ -990,6 +990,12 @@ "properties": { "command": { "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } } } } diff --git a/src/commands/debug/PostFuncDebugExecuteStep.ts b/src/commands/debug/PostFuncDebugExecuteStep.ts new file mode 100644 index 000000000..af6a92ba5 --- /dev/null +++ b/src/commands/debug/PostFuncDebugExecuteStep.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 { ActivityChildItem, ActivityChildType, activityInfoContext, AzureWizardExecuteStep, createContextValue, type ExecuteActivityOutput, type IActionContext } from "@microsoft/vscode-azext-utils"; +import { localize } from "../../localize"; + +export class PostFuncDebugExecuteStep extends AzureWizardExecuteStep { + public priority: number = 999; + public stepName: string = 'PostFuncDebugExecuteStep'; + + public async execute(_context: T): Promise { + // no-op + } + + public createSuccessOutput(context: T): ExecuteActivityOutput { + const terminateDebugSession: string = localize('connectMcpServer', 'Successfully terminated debug session.'); + return { + item: new ActivityChildItem({ + label: terminateDebugSession, + id: `${context.telemetry.properties.sessionId}-terminateDebugSession`, + activityType: ActivityChildType.Success, + contextValue: createContextValue([activityInfoContext, 'terminateDebugSession']), + // a little trick to remove the description timer on activity children + description: ' ' + }) + }; + } + + public createFailOutput(context: T): ExecuteActivityOutput { + const terminateDebugSession: string = localize('terminateDebugSessionFail', 'Failed to terminate debug session.'); + return { + item: new ActivityChildItem({ + label: terminateDebugSession, + id: `${context.telemetry.properties.sessionId}-terminateDebugSession-fail`, + activityType: ActivityChildType.Error, + contextValue: createContextValue([activityInfoContext, 'terminateDebugSessionFail']), + // a little trick to remove the description timer on activity children + description: ' ' + }) + }; + } + + public shouldExecute(context: T): boolean { + return true; + } +} diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 9fe2417d9..b88cdaf68 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -20,16 +20,25 @@ import { getWorkspaceSetting } from '../vsCodeConfig/settings'; const funcTaskReadyEmitter = new vscode.EventEmitter(); export const onDotnetFuncTaskReady = funcTaskReadyEmitter.event; +// flag used by func core tools to indicate to wait for the debugger to attach before starting the worker +const dotnetIsolatedDebugFlag = '--dotnet-isolated-debug'; +const enableJsonOutput = '--enable-json-output'; export async function startFuncProcessFromApi( buildPath: string, args: string[], env: { [key: string]: string } -): Promise<{ processId: string; success: boolean; error: string }> { - const result = { +): Promise<{ processId: string; success: boolean; error: string, stream: AsyncIterable | undefined }> { + const result: { + processId: string; + success: boolean; + error: string; + stream: AsyncIterable | undefined; + } = { processId: '', success: false, - error: '' + error: '', + stream: undefined }; let funcHostStartCmd: string = 'func host start'; @@ -66,6 +75,7 @@ export async function startFuncProcessFromApi( const taskInfo = await startFuncTask(context, workspaceFolder, buildPath, funcTask); result.processId = await pickChildProcess(taskInfo); result.success = true; + result.stream = taskInfo.stream; } catch (err) { const pError = parseError(err); result.error = pError.message; @@ -140,6 +150,9 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo const funcPort: string = await getFuncPortFromTaskOrProject(context, funcTask, workspaceFolder); let statusRequestTimeout: number = intervalMs; const maxTime: number = Date.now() + timeoutInSeconds * 1000; + const funcShellExecution = funcTask.execution as vscode.ShellExecution; + const debugModeOn = funcShellExecution.commandLine?.includes(dotnetIsolatedDebugFlag) && funcShellExecution.commandLine?.includes(enableJsonOutput); + while (Date.now() < maxTime) { if (taskError !== undefined) { throw taskError; @@ -147,27 +160,38 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo const taskInfo: IRunningFuncTask | undefined = runningFuncTaskMap.get(workspaceFolder, buildPath); if (taskInfo) { - for (const scheme of ['http', 'https']) { - const statusRequest: AzExtRequestPrepareOptions = { url: `${scheme}://localhost:${funcPort}/admin/host/status`, method: 'GET' }; - if (scheme === 'https') { - statusRequest.rejectUnauthorized = false; + if (debugModeOn) { + // if we are in dotnet isolated debug mode, we need to find the pid from the terminal output + // if there is no pid yet, keep waiting + const newPid = await getWorkerPidFromJsonOutput(taskInfo); + if (newPid) { + taskInfo.processId = newPid; + return taskInfo; } - - try { - // wait for status url to indicate functions host is running - const response = await sendRequestWithTimeout(context, statusRequest, statusRequestTimeout, undefined); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - if (response.parsedBody.state.toLowerCase() === 'running') { - funcTaskReadyEmitter.fire(workspaceFolder); - return taskInfo; + } else { + // otherwise, we have to wait for the status url to indicate the host is running + for (const scheme of ['http', 'https']) { + const statusRequest: AzExtRequestPrepareOptions = { url: `${scheme}://localhost:${funcPort}/admin/host/status`, method: 'GET' }; + if (scheme === 'https') { + statusRequest.rejectUnauthorized = false; } - } catch (error) { - if (requestUtils.isTimeoutError(error)) { - // Timeout likely means localhost isn't ready yet, but we'll increase the timeout each time it fails just in case it's a slow computer that can't handle a request that fast - statusRequestTimeout *= 2; - context.telemetry.measurements.maxStatusTimeout = statusRequestTimeout; - } else { - // ignore + + try { + // wait for status url to indicate functions host is running + const response = await sendRequestWithTimeout(context, statusRequest, statusRequestTimeout, undefined); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (response.parsedBody.state.toLowerCase() === 'running') { + funcTaskReadyEmitter.fire(workspaceFolder); + return taskInfo; + } + } catch (error) { + if (requestUtils.isTimeoutError(error)) { + // Timeout likely means localhost isn't ready yet, but we'll increase the timeout each time it fails just in case it's a slow computer that can't handle a request that fast + statusRequestTimeout *= 2; + context.telemetry.measurements.maxStatusTimeout = statusRequestTimeout; + } else { + // ignore + } } } } @@ -182,6 +206,23 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo } } +async function getWorkerPidFromJsonOutput(taskInfo: IRunningFuncTask): Promise { + // if there is no stream yet or if the output doesn't include the workerProcessId yet, then keep waiting + if (!taskInfo.stream) { + return; + } + + for await (const chunk of taskInfo.stream) { + if (chunk.includes(`{ "name":"dotnet-worker-startup", "workerProcessId" :`)) { + const matches = chunk.match(/"workerProcessId"\s*:\s*(\d+)/); + if (matches && matches.length > 1) { + return Number(matches[1]); + } + } + } + return; +} + type OSAgnosticProcess = { command: string | undefined; pid: number | string }; /** diff --git a/src/debug/FuncTaskProvider.ts b/src/debug/FuncTaskProvider.ts index e583003e9..ca24bfe81 100644 --- a/src/debug/FuncTaskProvider.ts +++ b/src/debug/FuncTaskProvider.ts @@ -104,6 +104,11 @@ export class FuncTaskProvider implements TaskProvider { private async createTask(context: IActionContext, command: string, folder: WorkspaceFolder, projectRoot: string | undefined, language: string | undefined, definition?: TaskDefinition): Promise { const funcCliPath = await getFuncCliPath(context, folder); + const args = (definition?.args || []) as string[]; + if (args.length > 0) { + command = `${command} ${args.join(' ')}`; + } + let commandLine: string = `${funcCliPath} ${command}`; if (language === ProjectLanguage.Python) { commandLine = venvUtils.convertToVenvCommand(commandLine, folder.uri.fsPath); diff --git a/src/extension.ts b/src/extension.ts index 47be7118f..b8eb3e5e0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,7 +28,7 @@ import { PowerShellDebugProvider } from './debug/PowerShellDebugProvider'; import { PythonDebugProvider } from './debug/PythonDebugProvider'; import { handleUri } from './downloadAzureProject/handleUri'; import { ext } from './extensionVariables'; -import { registerFuncHostTaskEvents } from './funcCoreTools/funcHostTask'; +import { registerFuncHostTaskEvents, terminalEventReader } from './funcCoreTools/funcHostTask'; import { validateFuncCoreToolsInstalled } from './funcCoreTools/validateFuncCoreToolsInstalled'; import { validateFuncCoreToolsIsLatest } from './funcCoreTools/validateFuncCoreToolsIsLatest'; import { getResourceGroupsApi } from './getExtensionApi'; @@ -154,4 +154,5 @@ export async function activateInternal(context: vscode.ExtensionContext, perfSta export async function deactivateInternal(): Promise { await emulatorClient.disposeAsync(); + terminalEventReader?.dispose(); } diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 20d6aa8db..c40b3bf89 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -3,13 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { registerEvent, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzureWizard, registerEvent, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as path from 'path'; import * as vscode from 'vscode'; import { tryGetFunctionProjectRoot } from '../commands/createNewProject/verifyIsProject'; +import { PostFuncDebugExecuteStep } from '../commands/debug/PostFuncDebugExecuteStep'; import { localSettingsFileName } from '../constants'; import { getLocalSettingsJson } from '../funcConfig/local.settings'; import { localize } from '../localize'; +import { createActivityContext } from '../utils/activityUtils'; import { cpUtils } from '../utils/cpUtils'; import { getWorkspaceSetting } from '../vsCodeConfig/settings'; @@ -17,6 +19,8 @@ export interface IRunningFuncTask { taskExecution: vscode.TaskExecution; processId: number; portNumber: string; + // stream for reading `func host start` output + stream: AsyncIterable | undefined; } interface DotnetDebugDebugConfiguration extends vscode.DebugConfiguration { @@ -81,28 +85,64 @@ export const onFuncTaskStarted = funcTaskStartedEmitter.event; export const buildPathToWorkspaceFolderMap = new Map(); const defaultFuncPort: string = '7071'; +const funcCommandRegex: RegExp = /(?:^|[\\/])(func(?:\.exe)?)\s+host\s+start$/i export function isFuncHostTask(task: vscode.Task): boolean { const commandLine: string | undefined = task.execution && (task.execution).commandLine; - return /(?:^|[\\/])(func(?:\.exe)?)\s+host\s+start$/i.test(commandLine || ''); + return funcCommandRegex.test(commandLine || ''); } +export function isFuncShellEvent(event: vscode.TerminalShellExecutionStartEvent): boolean { + const commandLine = event.execution && event.execution.commandLine; + return funcCommandRegex.test(commandLine.value || ''); +} + + +let latestTerminalShellExecutionEvent: vscode.TerminalShellExecutionStartEvent | undefined; +export let terminalEventReader: vscode.Disposable; export function registerFuncHostTaskEvents(): void { + // we need to register this listener before the func host task starts, so we can capture the terminal output stream + terminalEventReader = vscode.window.onDidStartTerminalShellExecution(async (terminalShellExecEvent) => { + /** + * This will pick up any terminal that starts a `func host start` command, including those started outside of tasks (e.g. via the command palette). + * But we don't actually access the terminal stream until the `func host start` task starts, at which time this will be pointing to the correct terminal + * */ + if (isFuncShellEvent(terminalShellExecEvent)) { + latestTerminalShellExecutionEvent = terminalShellExecEvent; + } + + }); registerEvent('azureFunctions.onDidStartTask', vscode.tasks.onDidStartTaskProcess, async (context: IActionContext, e: vscode.TaskProcessStartEvent) => { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; + + if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { const portNumber = await getFuncPortFromTaskOrProject(context, e.execution.task, e.execution.task.scope); - const runningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber }; + const runningFuncTask: IRunningFuncTask = { + processId: e.processId, + taskExecution: e.execution, + portNumber, + stream: latestTerminalShellExecutionEvent?.execution.read() + }; + runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask); funcTaskStartedEmitter.fire(e.execution.task.scope); } }); - registerEvent('azureFunctions.onDidEndTask', vscode.tasks.onDidEndTaskProcess, (context: IActionContext, e: vscode.TaskProcessEndEvent) => { + registerEvent('azureFunctions.onDidEndTask', vscode.tasks.onDidEndTaskProcess, async (context: IActionContext, e: vscode.TaskProcessEndEvent) => { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { + runngFuncTask.map.get runningFuncTaskMap.delete(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); + const wizardContext = Object.assign(context, await createActivityContext({ withChildren: true })); + const wizard = new AzureWizard(wizardContext, { + title: localize('funcTaskEnded', 'Function host task ended.'), + promptSteps: [], + executeSteps: [new PostFuncDebugExecuteStep()] + }); + await wizard.execute(); } }); @@ -146,7 +186,7 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol for (const runningFuncTaskItem of runningFuncTask) { if (!runningFuncTaskItem) break; if (terminate) { - runningFuncTaskItem.taskExecution.terminate() + runningFuncTaskItem.taskExecution.terminate(); } else { // Try to find the real func process by port first, fall back to shell PID await killFuncProcessByPortOrPid(runningFuncTaskItem, workspaceFolder);