diff --git a/package.json b/package.json index ffa8b5fcd..ff10d81de 100644 --- a/package.json +++ b/package.json @@ -985,6 +985,12 @@ "properties": { "command": { "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } } } } 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 8ccdf5d00..4c2ba218c 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -17,6 +17,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 { @@ -86,13 +88,36 @@ export function isFuncHostTask(task: vscode.Task): boolean { return /func (host )?start/i.test(commandLine || ''); } +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) => { + /** + * NOTE: there is no reliable way to link a terminal to a task due to the name and PID not updating in real time, + * so just keep updating to the latest event since the func task and its dependencies run in the same + * terminal (the terminal that we want to output) + * New tasks will create new `terminalShellExecutionEvents`, so we don't need to worry about picking up output from other terminals + * BUG: There's a current issue where if there is _only_ a func task in the tasks.json (as in it doesn't dependOn any other tasks), + * the onDidStartTerminalShellExecution does not fire at all. This should not impact most runtimes as they all have some sort of + * build task as a dependency. This is a bug on VS Code that I am working with Daniel to fix. + * */ + 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 = { + processId: e.processId, + taskExecution: e.execution, + portNumber, + stream: latestTerminalShellExecutionEvent?.execution.read() + }; + runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask); funcTaskStartedEmitter.fire(e.execution.task.scope); } @@ -146,7 +171,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);