From 6e5b61cc5a000f275b923252e10fbaec553cd248 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Fri, 24 Oct 2025 10:55:11 -0700 Subject: [PATCH 01/38] Implementation for debug-isolated flag and streaming func CLI output --- package.json | 11 ++- src/commands/pickFuncProcess.ts | 98 ++++++++++++++++----- src/debug/FuncTaskProvider.ts | 5 ++ src/funcCoreTools/funcHostTask.ts | 8 +- src/utils/stream.ts | 56 ++++++++++++ vscode.proposed.terminalDataWriteEvent.d.ts | 29 ++++++ 6 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 src/utils/stream.ts create mode 100644 vscode.proposed.terminalDataWriteEvent.d.ts diff --git a/package.json b/package.json index ffa8b5fcd..a3a515663 100644 --- a/package.json +++ b/package.json @@ -985,6 +985,12 @@ "properties": { "command": { "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -1504,5 +1510,8 @@ "runWizardCommandWithoutExecutionCommandId": "azureFunctions.agent.runWizardCommandWithoutExecution", "runWizardCommandWithInputsCommandId": "azureFunctions.agent.runWizardCommandWithInputs", "getAgentBenchmarkConfigsCommandId": "azureFunctions.agent.getAgentBenchmarkConfigs" - } + }, + "enabledApiProposals": [ + "terminalDataWriteEvent" + ] } diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 9fe2417d9..088827be5 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -25,11 +25,17 @@ 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 +72,7 @@ export async function startFuncProcessFromApi( const taskInfo = await startFuncTask(context, workspaceFolder, buildPath, funcTask); result.processId = await pickChildProcess(taskInfo); result.success = true; + result.stream = taskInfo.streamHandler.stream; } catch (err) { const pError = parseError(err); result.error = pError.message; @@ -140,6 +147,11 @@ 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 debugModeOn = funcTask.name.includes('--dotnet-isolated-debug') && funcTask.name.includes('--enable-json-output'); + let eventDisposable: vscode.Disposable | undefined; + let parentPid: number | undefined; + let asyncStreamIsSet: boolean = false; + while (Date.now() < maxTime) { if (taskError !== undefined) { throw taskError; @@ -147,27 +159,54 @@ 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; + // set up the stream on first try to capture terminal output + if (!asyncStreamIsSet) { + vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => { + const terminal = vscode.window.terminals.find(t => funcTask.name === t.name); + if (event.terminal === terminal) { + taskInfo.streamHandler.write(event.data); + } + }); + asyncStreamIsSet = true; + } + + if (debugModeOn) { + // if we are in dotnet isolated debug mode, we need to find the pid from the terminal output + if (!eventDisposable) { + // preserve the old pid to detect changes + parentPid = taskInfo.processId; + eventDisposable = await setEventPidByJsonOutput(taskInfo, funcTask.name); } - 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; + // if we are starting a dotnet isolated func host with json output enabled, we can find the pid directly from the output + if (taskInfo.processId !== parentPid) { + // we have to wait for the process id to be set from the terminal output + 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 +221,23 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo } } +async function setEventPidByJsonOutput(taskInfo: IRunningFuncTask, taskName: string): Promise { + const setPidByJsonOutputListener = vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => { + const terminal = vscode.window.terminals.find(t => taskName === t.name); + if (event.terminal === terminal) { + if (event.data.includes(`{ "name":"dotnet-worker-startup", "workerProcessId" :`)) { + const matches = event.data.match(/"workerProcessId"\s*:\s*(\d+)/); + if (matches && matches.length > 1) { + taskInfo.processId = Number(matches[1]); + setPidByJsonOutputListener.dispose(); + } + } + } + }); + + return setPidByJsonOutputListener; +} + type OSAgnosticProcess = { command: string | undefined; pid: number | string }; /** diff --git a/src/debug/FuncTaskProvider.ts b/src/debug/FuncTaskProvider.ts index e583003e9..a380d605a 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) { + command = `${command} ${args.join(' ')}`; + } + let commandLine: string = `${funcCliPath} ${command}`; if (language === ProjectLanguage.Python) { commandLine = venvUtils.convertToVenvCommand(commandLine, folder.uri.fsPath); diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 8ccdf5d00..eb5fd99c5 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -11,12 +11,14 @@ import { localSettingsFileName } from '../constants'; import { getLocalSettingsJson } from '../funcConfig/local.settings'; import { localize } from '../localize'; import { cpUtils } from '../utils/cpUtils'; +import { createAsyncStringStream, type AsyncStreamHandler } from '../utils/stream'; import { getWorkspaceSetting } from '../vsCodeConfig/settings'; export interface IRunningFuncTask { taskExecution: vscode.TaskExecution; processId: number; portNumber: string; + streamHandler: AsyncStreamHandler; } interface DotnetDebugDebugConfiguration extends vscode.DebugConfiguration { @@ -92,7 +94,8 @@ export function registerFuncHostTaskEvents(): void { 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 streamHandler = createAsyncStringStream(); + const runningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber, streamHandler }; runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask); funcTaskStartedEmitter.fire(e.execution.task.scope); } @@ -146,10 +149,11 @@ 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); + runningFuncTaskItem.streamHandler.end(); } } diff --git a/src/utils/stream.ts b/src/utils/stream.ts new file mode 100644 index 000000000..f02386efd --- /dev/null +++ b/src/utils/stream.ts @@ -0,0 +1,56 @@ +export type AsyncStreamHandler = { + stream: AsyncIterable; + write: (chunk: string) => void; + end: () => void; +}; + +export function createAsyncStringStream(): AsyncStreamHandler { + const queue: (string | null)[] = []; + let resolveNext: ((result: IteratorResult) => void) | null = null; + let done = false; + + const stream: AsyncIterable = { + [Symbol.asyncIterator](): AsyncIterator { + return { + next() { + return new Promise>(resolve => { + if (queue.length > 0) { + const value = queue.shift(); + if (value === null) { + resolve({ value: undefined, done: true }); + } else { + resolve({ value: value as string, done: false }); + } + } else if (done) { + resolve({ value: undefined, done: true }); + } else { + resolveNext = resolve; + } + }); + } + }; + } + }; + + function write(chunk: string) { + if (done) throw new Error("Cannot write to a ended stream"); + if (resolveNext) { + resolveNext({ value: chunk, done: false }); + resolveNext = null; + } else { + queue.push(chunk); + } + } + + function end() { + done = true; + if (resolveNext) { + resolveNext({ value: undefined, done: true }); + resolveNext = null; + } else { + queue.push(null); // sentinel for end + } + } + + return { stream, write, end }; +} diff --git a/vscode.proposed.terminalDataWriteEvent.d.ts b/vscode.proposed.terminalDataWriteEvent.d.ts new file mode 100644 index 000000000..c9c4c0e99 --- /dev/null +++ b/vscode.proposed.terminalDataWriteEvent.d.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/78502 + + export interface TerminalDataWriteEvent { + /** + * The {@link Terminal} for which the data was written. + */ + readonly terminal: Terminal; + /** + * The data being written. + */ + readonly data: string; + } + + namespace window { + /** + * An event which fires when the terminal's child pseudo-device is written to (the shell). + * In other words, this provides access to the raw data stream from the process running + * within the terminal, including VT sequences. + */ + export const onDidWriteTerminalData: Event; + } +} From e9c255d71ed6bd70c3926b04ac5a71de24851131 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 24 Oct 2025 11:09:30 -0700 Subject: [PATCH 02/38] Update src/utils/stream.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/utils/stream.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/stream.ts b/src/utils/stream.ts index f02386efd..0678c68f0 100644 --- a/src/utils/stream.ts +++ b/src/utils/stream.ts @@ -33,7 +33,7 @@ export function createAsyncStringStream(): AsyncStreamHandler { }; function write(chunk: string) { - if (done) throw new Error("Cannot write to a ended stream"); + if (done) throw new Error("Cannot write to an ended stream"); if (resolveNext) { resolveNext({ value: chunk, done: false }); resolveNext = null; From 067e04d2742254db6416ad1b5595b960c4ff5883 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Fri, 24 Oct 2025 11:13:59 -0700 Subject: [PATCH 03/38] Address copilot feedback --- src/commands/pickFuncProcess.ts | 6 +++++- src/utils/stream.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 088827be5..3ebbd66b5 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -161,11 +161,15 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo if (taskInfo) { // set up the stream on first try to capture terminal output if (!asyncStreamIsSet) { - vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => { + const outputReader = vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => { const terminal = vscode.window.terminals.find(t => funcTask.name === t.name); if (event.terminal === terminal) { taskInfo.streamHandler.write(event.data); } + + if (taskInfo.streamHandler.done) { + outputReader.dispose(); + } }); asyncStreamIsSet = true; } diff --git a/src/utils/stream.ts b/src/utils/stream.ts index f02386efd..cf65558b3 100644 --- a/src/utils/stream.ts +++ b/src/utils/stream.ts @@ -2,6 +2,7 @@ export type AsyncStreamHandler = { stream: AsyncIterable; write: (chunk: string) => void; end: () => void; + done: boolean; }; export function createAsyncStringStream(): AsyncStreamHandler { @@ -52,5 +53,5 @@ export function createAsyncStringStream(): AsyncStreamHandler { } } - return { stream, write, end }; + return { stream, write, end, done }; } From 606f1300a844d5e36e6dd784113038a09df199bc Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Fri, 24 Oct 2025 11:51:39 -0700 Subject: [PATCH 04/38] Refactor to set up stream in funcHostTasks --- src/commands/pickFuncProcess.ts | 16 ---------------- src/funcCoreTools/funcHostTask.ts | 21 ++++++++++++++++++++- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 3ebbd66b5..b12e8f212 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -150,7 +150,6 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo const debugModeOn = funcTask.name.includes('--dotnet-isolated-debug') && funcTask.name.includes('--enable-json-output'); let eventDisposable: vscode.Disposable | undefined; let parentPid: number | undefined; - let asyncStreamIsSet: boolean = false; while (Date.now() < maxTime) { if (taskError !== undefined) { @@ -159,21 +158,6 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo const taskInfo: IRunningFuncTask | undefined = runningFuncTaskMap.get(workspaceFolder, buildPath); if (taskInfo) { - // set up the stream on first try to capture terminal output - if (!asyncStreamIsSet) { - const outputReader = vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => { - const terminal = vscode.window.terminals.find(t => funcTask.name === t.name); - if (event.terminal === terminal) { - taskInfo.streamHandler.write(event.data); - } - - if (taskInfo.streamHandler.done) { - outputReader.dispose(); - } - }); - asyncStreamIsSet = true; - } - if (debugModeOn) { // if we are in dotnet isolated debug mode, we need to find the pid from the terminal output if (!eventDisposable) { diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index eb5fd99c5..e4c8db543 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -19,6 +19,7 @@ export interface IRunningFuncTask { processId: number; portNumber: string; streamHandler: AsyncStreamHandler; + outputReader: vscode.Disposable; } interface DotnetDebugDebugConfiguration extends vscode.DebugConfiguration { @@ -95,7 +96,19 @@ export function registerFuncHostTaskEvents(): void { if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { const portNumber = await getFuncPortFromTaskOrProject(context, e.execution.task, e.execution.task.scope); const streamHandler = createAsyncStringStream(); - const runningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber, streamHandler }; + const terminalName = e.execution.task.name; + const outputReader = vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => { + const terminal = vscode.window.terminals.find(t => terminalName === t.name); + if (event.terminal === terminal) { + runningFuncTask.streamHandler.write(event.data); + } + + if (runningFuncTask.streamHandler.done) { + outputReader.dispose(); + } + }); + + const runningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber, streamHandler, outputReader }; runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask); funcTaskStartedEmitter.fire(e.execution.task.scope); } @@ -154,6 +167,12 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol // Try to find the real func process by port first, fall back to shell PID await killFuncProcessByPortOrPid(runningFuncTaskItem, workspaceFolder); runningFuncTaskItem.streamHandler.end(); + runningFuncTaskItem.outputReader.dispose(); + } + + for await (const chunk of runningFuncTaskItem.streamHandler.stream) { + // Process each chunk of the stream + console.log(chunk); } } From 44e8382e009f3ba64cbb301414a24d7dd4b5a69f Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Fri, 24 Oct 2025 11:52:39 -0700 Subject: [PATCH 05/38] Delete test snippet --- src/funcCoreTools/funcHostTask.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index e4c8db543..73f5991c7 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -169,11 +169,6 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol runningFuncTaskItem.streamHandler.end(); runningFuncTaskItem.outputReader.dispose(); } - - for await (const chunk of runningFuncTaskItem.streamHandler.stream) { - // Process each chunk of the stream - console.log(chunk); - } } if (buildPath) { From 972654381465e81139c8539907e74b23ef240340 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Fri, 24 Oct 2025 11:53:17 -0700 Subject: [PATCH 06/38] Add headerto stream file --- src/utils/stream.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils/stream.ts b/src/utils/stream.ts index cd53d9932..1f7d39d7b 100644 --- a/src/utils/stream.ts +++ b/src/utils/stream.ts @@ -1,8 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + export type AsyncStreamHandler = { stream: AsyncIterable; write: (chunk: string) => void; end: () => void; - done: boolean; }; export function createAsyncStringStream(): AsyncStreamHandler { @@ -53,5 +57,5 @@ export function createAsyncStringStream(): AsyncStreamHandler { } } - return { stream, write, end, done }; + return { stream, write, end }; } From d4f3b56a0bb232eb032c8cf599916363166eccbc Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Fri, 24 Oct 2025 11:57:02 -0700 Subject: [PATCH 07/38] Remove the done call --- src/funcCoreTools/funcHostTask.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 73f5991c7..b8d0d10bb 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -102,10 +102,6 @@ export function registerFuncHostTaskEvents(): void { if (event.terminal === terminal) { runningFuncTask.streamHandler.write(event.data); } - - if (runningFuncTask.streamHandler.done) { - outputReader.dispose(); - } }); const runningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber, streamHandler, outputReader }; From c7618c76513d15a105e0f8fe05e3f6e520dbb818 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 24 Oct 2025 12:05:34 -0700 Subject: [PATCH 08/38] Update src/debug/FuncTaskProvider.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/debug/FuncTaskProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/debug/FuncTaskProvider.ts b/src/debug/FuncTaskProvider.ts index a380d605a..ca24bfe81 100644 --- a/src/debug/FuncTaskProvider.ts +++ b/src/debug/FuncTaskProvider.ts @@ -105,7 +105,7 @@ 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) { + if (args.length > 0) { command = `${command} ${args.join(' ')}`; } From b8dd0bf9c179f62c5a84f669a63f19ef38b1909a Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 29 Oct 2025 13:31:09 -0700 Subject: [PATCH 09/38] WIP waiting for VSCode insiders fix --- src/funcCoreTools/funcHostTask.ts | 41 ++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index b8d0d10bb..d9df35da1 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -89,21 +89,56 @@ export function isFuncHostTask(task: vscode.Task): boolean { return /func (host )?start/i.test(commandLine || ''); } +export function isFuncHostTerminalShell(shell: vscode.TerminalShellExecutionStartEvent): boolean { + const commandLine: string | undefined = shell.execution && shell.execution.commandLine.value; + return /func (host )?start/i.test(commandLine || ''); +} + +const streamHandlerMap: Map = new Map(); export function registerFuncHostTaskEvents(): void { registerEvent('azureFunctions.onDidStartTask', vscode.tasks.onDidStartTaskProcess, async (context: IActionContext, e: vscode.TaskProcessStartEvent) => { + const startHandler = vscode.window.onDidStartTerminalShellExecution(async (terminalShellExecEvent) => { + console.log(`Terminal name: ${terminalShellExecEvent.terminal.name}`); + console.log(`Task name: ${terminalShellExecEvent.execution.commandLine.value}`); + console.log(`Process ID: ${e.processId}`); + console.log(`Terminal PID: ${await terminalShellExecEvent.terminal.processId}`); + if (isFuncHostTerminalShell(terminalShellExecEvent)) { + if (!streamHandlerMap.has(e.processId.toString())) { + // only set it up the first time we are seeing this pid + const streamHandler = createAsyncStringStream(); + streamHandler.stream = terminalShellExecEvent.execution.read(); + streamHandlerMap.set(e.processId.toString(), streamHandler); + } + } + }); + 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 streamHandler = createAsyncStringStream(); const terminalName = e.execution.task.name; + // const terminal = vscode.window.terminals.find(t => terminalName === t.name); + const portNumber = await getFuncPortFromTaskOrProject(context, e.execution.task, e.execution.task.scope); + const endHandler = vscode.window.onDidEndTerminalShellExecution(async (terminalShellExecEvent) => { + // reserved for closing the event handlers + if (isFuncHostTerminalShell(terminalShellExecEvent)) { + for await (const chunk of runningFuncTask.streamHandler.stream) { + console.log(chunk); + } + + startHandler.dispose(); + endHandler.dispose(); + } + }); + const outputReader = vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => { const terminal = vscode.window.terminals.find(t => terminalName === t.name); if (event.terminal === terminal) { - runningFuncTask.streamHandler.write(event.data); + // runningFuncTask.streamHandler.write(event.data); } }); + const streamHandler = streamHandlerMap.get(e.processId.toString()) || createAsyncStringStream(); const runningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber, streamHandler, outputReader }; runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask); funcTaskStartedEmitter.fire(e.execution.task.scope); From d673a73800ad692edbecdb604d8385ad6ee27549 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 30 Oct 2025 15:24:48 -0700 Subject: [PATCH 10/38] Use onDidStartTerminalShellExecution API instead of proposed --- src/commands/pickFuncProcess.ts | 43 +++++++++------------ src/funcCoreTools/funcHostTask.ts | 62 ++++++++++--------------------- src/utils/stream.ts | 61 ------------------------------ 3 files changed, 37 insertions(+), 129 deletions(-) delete mode 100644 src/utils/stream.ts diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index b12e8f212..54834e395 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -72,7 +72,7 @@ export async function startFuncProcessFromApi( const taskInfo = await startFuncTask(context, workspaceFolder, buildPath, funcTask); result.processId = await pickChildProcess(taskInfo); result.success = true; - result.stream = taskInfo.streamHandler.stream; + result.stream = taskInfo.stream; } catch (err) { const pError = parseError(err); result.error = pError.message; @@ -148,8 +148,6 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo let statusRequestTimeout: number = intervalMs; const maxTime: number = Date.now() + timeoutInSeconds * 1000; const debugModeOn = funcTask.name.includes('--dotnet-isolated-debug') && funcTask.name.includes('--enable-json-output'); - let eventDisposable: vscode.Disposable | undefined; - let parentPid: number | undefined; while (Date.now() < maxTime) { if (taskError !== undefined) { @@ -160,15 +158,10 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo if (taskInfo) { if (debugModeOn) { // if we are in dotnet isolated debug mode, we need to find the pid from the terminal output - if (!eventDisposable) { - // preserve the old pid to detect changes - parentPid = taskInfo.processId; - eventDisposable = await setEventPidByJsonOutput(taskInfo, funcTask.name); - } - - // if we are starting a dotnet isolated func host with json output enabled, we can find the pid directly from the output - if (taskInfo.processId !== parentPid) { - // we have to wait for the process id to be set from the terminal output + // if there is no pid yet, keep waiting + const newPid = await setEventPidByJsonOutput(taskInfo); + if (newPid) { + taskInfo.processId = newPid; return taskInfo; } } else { @@ -209,21 +202,21 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo } } -async function setEventPidByJsonOutput(taskInfo: IRunningFuncTask, taskName: string): Promise { - const setPidByJsonOutputListener = vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => { - const terminal = vscode.window.terminals.find(t => taskName === t.name); - if (event.terminal === terminal) { - if (event.data.includes(`{ "name":"dotnet-worker-startup", "workerProcessId" :`)) { - const matches = event.data.match(/"workerProcessId"\s*:\s*(\d+)/); - if (matches && matches.length > 1) { - taskInfo.processId = Number(matches[1]); - setPidByJsonOutputListener.dispose(); - } +async function setEventPidByJsonOutput(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 setPidByJsonOutputListener; + } + return; } type OSAgnosticProcess = { command: string | undefined; pid: number | string }; diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index d9df35da1..5d712ad37 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -11,15 +11,16 @@ import { localSettingsFileName } from '../constants'; import { getLocalSettingsJson } from '../funcConfig/local.settings'; import { localize } from '../localize'; import { cpUtils } from '../utils/cpUtils'; -import { createAsyncStringStream, type AsyncStreamHandler } from '../utils/stream'; import { getWorkspaceSetting } from '../vsCodeConfig/settings'; export interface IRunningFuncTask { taskExecution: vscode.TaskExecution; processId: number; portNumber: string; - streamHandler: AsyncStreamHandler; - outputReader: vscode.Disposable; + // there is always an event handler listening to `onDidStartTerminalShellExecution` when a func task starts to populate stream + terminalEventReader: vscode.Disposable; + // stream for reading `func host start` output + stream: AsyncIterable | undefined; } interface DotnetDebugDebugConfiguration extends vscode.DebugConfiguration { @@ -93,62 +94,39 @@ export function isFuncHostTerminalShell(shell: vscode.TerminalShellExecutionStar const commandLine: string | undefined = shell.execution && shell.execution.commandLine.value; return /func (host )?start/i.test(commandLine || ''); } - -const streamHandlerMap: Map = new Map(); +let latestTerminalShellExecutionEvent: vscode.TerminalShellExecutionStartEvent | undefined; export function registerFuncHostTaskEvents(): void { registerEvent('azureFunctions.onDidStartTask', vscode.tasks.onDidStartTaskProcess, async (context: IActionContext, e: vscode.TaskProcessStartEvent) => { - const startHandler = vscode.window.onDidStartTerminalShellExecution(async (terminalShellExecEvent) => { - console.log(`Terminal name: ${terminalShellExecEvent.terminal.name}`); - console.log(`Task name: ${terminalShellExecEvent.execution.commandLine.value}`); - console.log(`Process ID: ${e.processId}`); - console.log(`Terminal PID: ${await terminalShellExecEvent.terminal.processId}`); - if (isFuncHostTerminalShell(terminalShellExecEvent)) { - if (!streamHandlerMap.has(e.processId.toString())) { - // only set it up the first time we are seeing this pid - const streamHandler = createAsyncStringStream(); - streamHandler.stream = terminalShellExecEvent.execution.read(); - streamHandlerMap.set(e.processId.toString(), streamHandler); - } - } + const terminalEventReader = vscode.window.onDidStartTerminalShellExecution(async (terminalShellExecEvent) => { + latestTerminalShellExecutionEvent = terminalShellExecEvent; }); context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { - const terminalName = e.execution.task.name; - // const terminal = vscode.window.terminals.find(t => terminalName === t.name); const portNumber = await getFuncPortFromTaskOrProject(context, e.execution.task, e.execution.task.scope); - const endHandler = vscode.window.onDidEndTerminalShellExecution(async (terminalShellExecEvent) => { - // reserved for closing the event handlers - if (isFuncHostTerminalShell(terminalShellExecEvent)) { - for await (const chunk of runningFuncTask.streamHandler.stream) { - console.log(chunk); - } + const runningFuncTask = { + processId: e.processId, + taskExecution: e.execution, + portNumber, + terminalEventReader, + stream: latestTerminalShellExecutionEvent?.execution.read() + }; - startHandler.dispose(); - endHandler.dispose(); - } - }); - - const outputReader = vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => { - const terminal = vscode.window.terminals.find(t => terminalName === t.name); - if (event.terminal === terminal) { - // runningFuncTask.streamHandler.write(event.data); - } - }); - - const streamHandler = streamHandlerMap.get(e.processId.toString()) || createAsyncStringStream(); - const runningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber, streamHandler, outputReader }; 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)) { + const runningFuncTask = runningFuncTaskMap.get(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); + if (runningFuncTask) { + runningFuncTask.terminalEventReader.dispose(); + } runningFuncTaskMap.delete(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); } }); @@ -197,8 +175,6 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol } else { // Try to find the real func process by port first, fall back to shell PID await killFuncProcessByPortOrPid(runningFuncTaskItem, workspaceFolder); - runningFuncTaskItem.streamHandler.end(); - runningFuncTaskItem.outputReader.dispose(); } } diff --git a/src/utils/stream.ts b/src/utils/stream.ts deleted file mode 100644 index 1f7d39d7b..000000000 --- a/src/utils/stream.ts +++ /dev/null @@ -1,61 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export type AsyncStreamHandler = { - stream: AsyncIterable; - write: (chunk: string) => void; - end: () => void; -}; - -export function createAsyncStringStream(): AsyncStreamHandler { - const queue: (string | null)[] = []; - let resolveNext: ((result: IteratorResult) => void) | null = null; - let done = false; - - const stream: AsyncIterable = { - [Symbol.asyncIterator](): AsyncIterator { - return { - next() { - return new Promise>(resolve => { - if (queue.length > 0) { - const value = queue.shift(); - if (value === null) { - resolve({ value: undefined, done: true }); - } else { - resolve({ value: value as string, done: false }); - } - } else if (done) { - resolve({ value: undefined, done: true }); - } else { - resolveNext = resolve; - } - }); - } - }; - } - }; - - function write(chunk: string) { - if (done) throw new Error("Cannot write to an ended stream"); - if (resolveNext) { - resolveNext({ value: chunk, done: false }); - resolveNext = null; - } else { - queue.push(chunk); - } - } - - function end() { - done = true; - if (resolveNext) { - resolveNext({ value: undefined, done: true }); - resolveNext = null; - } else { - queue.push(null); // sentinel for end - } - } - - return { stream, write, end }; -} From 2dfd68d32b2ee26e2bf6314eb5085566f15c233e Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 30 Oct 2025 15:25:10 -0700 Subject: [PATCH 11/38] Remove proposed files --- package.json | 5 +--- vscode.proposed.terminalDataWriteEvent.d.ts | 29 --------------------- 2 files changed, 1 insertion(+), 33 deletions(-) delete mode 100644 vscode.proposed.terminalDataWriteEvent.d.ts diff --git a/package.json b/package.json index a3a515663..ff10d81de 100644 --- a/package.json +++ b/package.json @@ -1510,8 +1510,5 @@ "runWizardCommandWithoutExecutionCommandId": "azureFunctions.agent.runWizardCommandWithoutExecution", "runWizardCommandWithInputsCommandId": "azureFunctions.agent.runWizardCommandWithInputs", "getAgentBenchmarkConfigsCommandId": "azureFunctions.agent.getAgentBenchmarkConfigs" - }, - "enabledApiProposals": [ - "terminalDataWriteEvent" - ] + } } diff --git a/vscode.proposed.terminalDataWriteEvent.d.ts b/vscode.proposed.terminalDataWriteEvent.d.ts deleted file mode 100644 index c9c4c0e99..000000000 --- a/vscode.proposed.terminalDataWriteEvent.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -declare module 'vscode' { - - // https://github.com/microsoft/vscode/issues/78502 - - export interface TerminalDataWriteEvent { - /** - * The {@link Terminal} for which the data was written. - */ - readonly terminal: Terminal; - /** - * The data being written. - */ - readonly data: string; - } - - namespace window { - /** - * An event which fires when the terminal's child pseudo-device is written to (the shell). - * In other words, this provides access to the raw data stream from the process running - * within the terminal, including VT sequences. - */ - export const onDidWriteTerminalData: Event; - } -} From 8a751c8e2032648ee01beede4bb63eee9e86c0eb Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 30 Oct 2025 15:34:07 -0700 Subject: [PATCH 12/38] Add note --- src/funcCoreTools/funcHostTask.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 5d712ad37..396a6bf85 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -98,6 +98,12 @@ let latestTerminalShellExecutionEvent: vscode.TerminalShellExecutionStartEvent | export function registerFuncHostTaskEvents(): void { registerEvent('azureFunctions.onDidStartTask', vscode.tasks.onDidStartTaskProcess, async (context: IActionContext, e: vscode.TaskProcessStartEvent) => { const 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 + * */ latestTerminalShellExecutionEvent = terminalShellExecEvent; }); From e5adbb9bd2ef13ee6a995bf0bc6993e02c8e1bb4 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 30 Oct 2025 15:35:56 -0700 Subject: [PATCH 13/38] Remove unused async moniker --- src/funcCoreTools/funcHostTask.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 396a6bf85..b59cacaef 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -137,7 +137,7 @@ export function registerFuncHostTaskEvents(): void { } }); - registerEvent('azureFunctions.onDidTerminateDebugSession', vscode.debug.onDidTerminateDebugSession, async (context: IActionContext, debugSession: vscode.DebugSession) => { + registerEvent('azureFunctions.onDidTerminateDebugSession', vscode.debug.onDidTerminateDebugSession, (context: IActionContext, debugSession: vscode.DebugSession) => { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; From 6454ca8d344bd9ada7d31150ef7427608de7c4fe Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 30 Oct 2025 15:37:12 -0700 Subject: [PATCH 14/38] Whoops, wrong async --- src/funcCoreTools/funcHostTask.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index b59cacaef..015fd76be 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -125,7 +125,7 @@ export function registerFuncHostTaskEvents(): void { } }); - registerEvent('azureFunctions.onDidEndTask', vscode.tasks.onDidEndTaskProcess, async (context: IActionContext, e: vscode.TaskProcessEndEvent) => { + registerEvent('azureFunctions.onDidEndTask', vscode.tasks.onDidEndTaskProcess, (context: IActionContext, e: vscode.TaskProcessEndEvent) => { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { @@ -137,7 +137,7 @@ export function registerFuncHostTaskEvents(): void { } }); - registerEvent('azureFunctions.onDidTerminateDebugSession', vscode.debug.onDidTerminateDebugSession, (context: IActionContext, debugSession: vscode.DebugSession) => { + registerEvent('azureFunctions.onDidTerminateDebugSession', vscode.debug.onDidTerminateDebugSession, async (context: IActionContext, debugSession: vscode.DebugSession) => { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; From 544cd45f5a2827a3f9c602e17546e1bff682b641 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 30 Oct 2025 15:47:07 -0700 Subject: [PATCH 15/38] Little bit of cleaning --- src/commands/pickFuncProcess.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 54834e395..83b7f79a4 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -20,6 +20,9 @@ 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, @@ -147,7 +150,8 @@ 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 debugModeOn = funcTask.name.includes('--dotnet-isolated-debug') && funcTask.name.includes('--enable-json-output'); + const funcShellExecution = funcTask.execution as vscode.ShellExecution; + const debugModeOn = funcShellExecution.commandLine?.includes(dotnetIsolatedDebugFlag) && funcTask.name.includes(enableJsonOutput); while (Date.now() < maxTime) { if (taskError !== undefined) { From 52780f727e746936087f0a6a2f8f4848796c3fab Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 30 Oct 2025 15:50:22 -0700 Subject: [PATCH 16/38] Remove unusued function helper --- src/funcCoreTools/funcHostTask.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 015fd76be..63424676d 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -90,10 +90,6 @@ export function isFuncHostTask(task: vscode.Task): boolean { return /func (host )?start/i.test(commandLine || ''); } -export function isFuncHostTerminalShell(shell: vscode.TerminalShellExecutionStartEvent): boolean { - const commandLine: string | undefined = shell.execution && shell.execution.commandLine.value; - return /func (host )?start/i.test(commandLine || ''); -} let latestTerminalShellExecutionEvent: vscode.TerminalShellExecutionStartEvent | undefined; export function registerFuncHostTaskEvents(): void { registerEvent('azureFunctions.onDidStartTask', vscode.tasks.onDidStartTaskProcess, async (context: IActionContext, e: vscode.TaskProcessStartEvent) => { From 3dc0fc500a9b762fa626a6dc2eac552af17bdaf9 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 30 Oct 2025 15:54:46 -0700 Subject: [PATCH 17/38] Last commit, I swears it --- src/commands/pickFuncProcess.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 83b7f79a4..8283c0b38 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -151,7 +151,7 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo let statusRequestTimeout: number = intervalMs; const maxTime: number = Date.now() + timeoutInSeconds * 1000; const funcShellExecution = funcTask.execution as vscode.ShellExecution; - const debugModeOn = funcShellExecution.commandLine?.includes(dotnetIsolatedDebugFlag) && funcTask.name.includes(enableJsonOutput); + const debugModeOn = funcShellExecution.commandLine?.includes(dotnetIsolatedDebugFlag) && funcShellExecution.commandLine?.includes(enableJsonOutput); while (Date.now() < maxTime) { if (taskError !== undefined) { From e36604c2eda7ef4aa6c0877b200fcb8b16e978de Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Mon, 3 Nov 2025 10:59:36 -0800 Subject: [PATCH 18/38] Move event handler, added note --- src/extension.ts | 3 ++- src/funcCoreTools/funcHostTask.ts | 32 +++++++++++++++---------------- 2 files changed, 17 insertions(+), 18 deletions(-) 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 63424676d..4c2ba218c 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -17,8 +17,6 @@ export interface IRunningFuncTask { taskExecution: vscode.TaskExecution; processId: number; portNumber: string; - // there is always an event handler listening to `onDidStartTerminalShellExecution` when a func task starts to populate stream - terminalEventReader: vscode.Disposable; // stream for reading `func host start` output stream: AsyncIterable | undefined; } @@ -91,28 +89,32 @@ export function isFuncHostTask(task: vscode.Task): boolean { } 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) => { - const 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 - * */ - latestTerminalShellExecutionEvent = terminalShellExecEvent; - }); - 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, - terminalEventReader, stream: latestTerminalShellExecutionEvent?.execution.read() }; @@ -125,10 +127,6 @@ export function registerFuncHostTaskEvents(): void { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { - const runningFuncTask = runningFuncTaskMap.get(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); - if (runningFuncTask) { - runningFuncTask.terminalEventReader.dispose(); - } runningFuncTaskMap.delete(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); } }); From 5bfbb42b54ced5dfcf06850784e17d59a3531db9 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Mon, 3 Nov 2025 11:03:54 -0800 Subject: [PATCH 19/38] Rename function due to PR feedback --- src/commands/pickFuncProcess.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 8283c0b38..b88cdaf68 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -163,7 +163,7 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo 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 setEventPidByJsonOutput(taskInfo); + const newPid = await getWorkerPidFromJsonOutput(taskInfo); if (newPid) { taskInfo.processId = newPid; return taskInfo; @@ -206,7 +206,7 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo } } -async function setEventPidByJsonOutput(taskInfo: IRunningFuncTask): Promise { +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; From bc365fa6b29ce1b8a7d9c76c7b2aaee33e621773 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 20 Nov 2025 16:36:40 -0800 Subject: [PATCH 20/38] Push up WIP --- .../debug/PostFuncDebugExecuteStep.ts | 48 +++++++++++++++++++ src/funcCoreTools/funcHostTask.ts | 38 ++++++++++----- 2 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 src/commands/debug/PostFuncDebugExecuteStep.ts 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/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 5673778c3..dbaae2806 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'; @@ -83,26 +85,31 @@ 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) => { /** - * 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. + * 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 * */ - latestTerminalShellExecutionEvent = terminalShellExecEvent; + if (isFuncShellEvent(terminalShellExecEvent)) { + latestTerminalShellExecutionEvent = terminalShellExecEvent; + } + }); registerEvent('azureFunctions.onDidStartTask', vscode.tasks.onDidStartTaskProcess, async (context: IActionContext, e: vscode.TaskProcessStartEvent) => { context.errorHandling.suppressDisplay = true; @@ -111,7 +118,7 @@ export function registerFuncHostTaskEvents(): void { if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { const portNumber = await getFuncPortFromTaskOrProject(context, e.execution.task, e.execution.task.scope); - const runningFuncTask = { + const runningFuncTask: IRunningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber, @@ -123,11 +130,18 @@ export function registerFuncHostTaskEvents(): void { } }); - 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)) { 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(); } }); From 5d5b38f661627ac656b7a9f1c449685552ef69e5 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 20 Nov 2025 16:37:05 -0800 Subject: [PATCH 21/38] Push up WIP --- src/funcCoreTools/funcHostTask.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index dbaae2806..c40b3bf89 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -134,6 +134,7 @@ export function registerFuncHostTaskEvents(): void { 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, { From 577a57dcf50cdc9aa4045c33ef00e971c4660000 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Mon, 24 Nov 2025 19:18:06 -0800 Subject: [PATCH 22/38] WIP for postDebug copilot analysis --- package-lock.json | 23 ++++++- .../debug/PostFuncDebugExecuteStep.ts | 60 +++++++++++++++++++ src/funcCoreTools/funcHostTask.ts | 36 +++++++---- .../dotnet/executeDotnetTemplateCommand.ts | 2 +- src/tree/localProject/LocalProjectTreeItem.ts | 9 +-- 5 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 src/commands/debug/PostFuncDebugExecuteStep.ts diff --git a/package-lock.json b/package-lock.json index 4573244a7..37ee1b07d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1198,6 +1198,7 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-azureappsettings/-/vscode-azext-azureappsettings-0.2.11.tgz", "integrity": "sha512-zu6RedxoVEgOTyTqyeo5HHPowK3aEYyD9if96b3TF3fkiuxuRlR/19V0qmoKxF6w8nYYhuUDScL2i+zlcu9+xQ==", + "peer": true, "dependencies": { "@microsoft/vscode-azext-utils": "^3.4.2" } @@ -1572,6 +1573,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -1861,7 +1863,8 @@ "version": "16.18.126", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", - "devOptional": true + "devOptional": true, + "peer": true }, "node_modules/@types/picomatch": { "version": "3.0.2", @@ -1977,6 +1980,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -2011,6 +2015,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -2594,6 +2599,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2648,6 +2654,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3183,6 +3190,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -3245,6 +3253,7 @@ "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", "hasInstallScript": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -4548,6 +4557,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4649,6 +4659,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -7652,6 +7663,7 @@ "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", "dev": true, + "peer": true, "dependencies": { "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", @@ -10095,6 +10107,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10402,7 +10415,8 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "peer": true }, "node_modules/tsutils": { "version": "3.21.0", @@ -10590,6 +10604,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10795,6 +10810,7 @@ "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", "hasInstallScript": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -11056,6 +11072,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -11102,6 +11119,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -11203,6 +11221,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/src/commands/debug/PostFuncDebugExecuteStep.ts b/src/commands/debug/PostFuncDebugExecuteStep.ts new file mode 100644 index 000000000..fffa7ca7e --- /dev/null +++ b/src/commands/debug/PostFuncDebugExecuteStep.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * 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, randomUtils, type ExecuteActivityContext, type ExecuteActivityOutput } from "@microsoft/vscode-azext-utils"; +import { stripVTControlCharacters } from "node:util"; + +export class PostFuncDebugExecuteStep extends AzureWizardExecuteStep { + public priority: number = 999; + public stepName: string = 'PostFuncDebugExecuteStep'; + + public constructor(readonly logs: string[]) { + super(); + } + + public async execute(context: T): Promise { + const errorLogs: string[] = []; + const redAnsiRegex = /\x1b\[(?:[0-9;]*31m|[0-9;]*91m|38;5;(9|1)m)/; + const functionErrors = [ + /No job functions found/i, + /Worker was unable to load entry point/i, + /SyntaxError:/i, + /Cannot find module/i, + /Failed to start Worker Channel/i, + /Serialization and deserialization.*not supported/i + ]; + for (const log of this.logs) { + if (redAnsiRegex.test(log) || functionErrors.some(err => err.test(log))) { + errorLogs.push(log); + } + } + + if (errorLogs.length > 0) { + this._logs = stripVTControlCharacters(errorLogs.join('\n')); + context.activityAttributes = context.activityAttributes || {}; + context.activityAttributes.logs = errorLogs.map(log => { return { content: stripVTControlCharacters(log) }; }); + throw new Error('Function host encountered errors during startup. See logs for details.'); + } + + return; + } + + public shouldExecute(_context: T): boolean { + return true; + } + + public createFailOutput(_context: T): ExecuteActivityOutput { + return { + item: new ActivityChildItem({ + label: 'Function host encountered errors during debugging. Click to have Copilot help diagnose the issue.', + id: `${randomUtils.getRandomHexString(8)}-terminateDebugSession-fail`, + activityType: ActivityChildType.Error, + contextValue: createContextValue([activityInfoContext, 'terminateDebugSessionFail']), + // a little trick to remove the description timer on activity children + description: ' ' + }) + }; + } +} diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 6228f4cdc..963b16c4a 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -21,6 +21,7 @@ export interface IRunningFuncTask { portNumber: string; // stream for reading `func host start` output stream: AsyncIterable | undefined; + logs: string[]; } interface DotnetDebugDebugConfiguration extends vscode.DebugConfiguration { @@ -79,7 +80,7 @@ class RunningFunctionTaskMap { export const runningFuncTaskMap: RunningFunctionTaskMap = new RunningFunctionTaskMap(); -const funcTaskStartedEmitter = new vscode.EventEmitter(); +const funcTaskStartedEmitter = new vscode.EventEmitter<{ scope: vscode.WorkspaceFolder | vscode.TaskScope, execution?: vscode.ShellExecution }>(); export const onFuncTaskStarted = funcTaskStartedEmitter.event; export const buildPathToWorkspaceFolderMap = new Map(); @@ -115,15 +116,17 @@ export function registerFuncHostTaskEvents(): void { if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { const portNumber = await getFuncPortFromTaskOrProject(context, e.execution.task, e.execution.task.scope); + const logs: string[] = []; const runningFuncTask: IRunningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber, - stream: latestTerminalShellExecutionEvent?.execution.read() + stream: latestTerminalShellExecutionEvent?.execution.read(), + logs }; runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask); - funcTaskStartedEmitter.fire(e.execution.task.scope); + funcTaskStartedEmitter.fire({ scope: e.execution.task.scope, execution: e.execution.task.execution as vscode.ShellExecution }); } }); @@ -131,20 +134,29 @@ export function registerFuncHostTaskEvents(): void { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { - const task = runningFuncTaskMap.get(e.execution.task.scope); - if (task && task.stream) { - for await (const streamLine of task.stream || []) { - console.log(streamLine); - } - } - 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 task = runningFuncTaskMap.get(e.execution.task.scope!, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); + const wizardContext = Object.assign(context, await createActivityContext()); + wizardContext.activityTitle = localize('funcTaskEnded', 'Function host task ended.'); + const wizard = new AzureWizard(wizardContext, { title: localize('funcTaskEnded', 'Function host task ended.'), + promptSteps: [], - executeSteps: [new PostFuncDebugExecuteStep()] + executeSteps: [new PostFuncDebugExecuteStep(task?.logs ?? [])] }); await wizard.execute(); + runningFuncTaskMap.delete(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); + } + }); + + onFuncTaskStarted(async ({ scope, execution }) => { + const task = runningFuncTaskMap.get(scope, execution?.options?.cwd); + if (!task) { + return; + } + + for await (const chunk of task.stream ?? []) { + task.logs.push(chunk); } }); diff --git a/src/templates/dotnet/executeDotnetTemplateCommand.ts b/src/templates/dotnet/executeDotnetTemplateCommand.ts index 5ce3495d3..8f025c702 100644 --- a/src/templates/dotnet/executeDotnetTemplateCommand.ts +++ b/src/templates/dotnet/executeDotnetTemplateCommand.ts @@ -62,7 +62,7 @@ async function getFramework(context: IActionContext, workingDirectory: string | } // Prioritize "LTS", then "Current", then "Preview" - const netVersions: string[] = ['6.0', '7.0', '8.0', '9.0']; + const netVersions: string[] = ['6.0', '7.0', '8.0', '9.0', '10.0']; const semVersions: SemVer[] = netVersions.map(v => semVerCoerce(v) as SemVer); let pickedVersion: SemVer | undefined; diff --git a/src/tree/localProject/LocalProjectTreeItem.ts b/src/tree/localProject/LocalProjectTreeItem.ts index ab0f4c35e..d9dcaeb0b 100644 --- a/src/tree/localProject/LocalProjectTreeItem.ts +++ b/src/tree/localProject/LocalProjectTreeItem.ts @@ -58,8 +58,9 @@ export class LocalProjectTreeItem extends LocalProjectTreeItemBase implements Di this._disposables.push(createRefreshFileWatcher(this, path.join(this.effectiveProjectPath, '*', functionJsonFileName))); this._disposables.push(createRefreshFileWatcher(this, path.join(this.effectiveProjectPath, localSettingsFileName))); - this._disposables.push(onFuncTaskStarted(async scope => this.onFuncTaskChanged(scope))); - this._disposables.push(onDotnetFuncTaskReady(async scope => this.onFuncTaskChanged(scope))); + this._disposables.push(onFuncTaskStarted(async event => this.onFuncTaskChanged(event))); + // this._disposables.push(onFuncTaskStarted(async scope => this.onFuncTaskChanged(scope))); + this._disposables.push(onDotnetFuncTaskReady(async scope => this.onFuncTaskChanged({ scope }))); this._localFunctionsTreeItem = new LocalFunctionsTreeItem(this); this._localSettingsTreeItem = new AppSettingsTreeItem(this, new LocalSettingsClientProvider(this.workspaceFolder), ext.prefix, { @@ -123,9 +124,9 @@ export class LocalProjectTreeItem extends LocalProjectTreeItemBase implements Di await this.project.setApplicationSetting(context, key, value); } - private async onFuncTaskChanged(scope: WorkspaceFolder | TaskScope | undefined): Promise { + private async onFuncTaskChanged(event: { scope: WorkspaceFolder | TaskScope | undefined }): Promise { await callWithTelemetryAndErrorHandling('onFuncTaskChanged', async (context: IActionContext) => { - if (this.workspaceFolder === scope) { + if (this.workspaceFolder === event.scope) { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; await this.refresh(context); From e8b519d3c310a7a4e4f77ab8f9659550844ad45d Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Tue, 25 Nov 2025 10:35:16 -0800 Subject: [PATCH 23/38] Add debug command activity --- src/commands/CommandAttributes.ts | 8 ++++++++ src/funcCoreTools/funcHostTask.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/src/commands/CommandAttributes.ts b/src/commands/CommandAttributes.ts index f0f961bad..c4eaaed96 100644 --- a/src/commands/CommandAttributes.ts +++ b/src/commands/CommandAttributes.ts @@ -31,4 +31,12 @@ export class CommandAttributes { ], }; + static readonly Debug: ActivityAttributes = { + description: "Starts the Azure Functions host in debug mode, allowing you to set breakpoints and step through your function code locally using a debugger.", + troubleshooting: [ + "Function host fails to start — check the output logs for errors related to your function code or configuration.", + "Breakpoints are not being hit — ensure that the debugger is properly attached and that you're running the function host in debug mode.", + "Port conflicts — verify that the ports required for debugging (e.g., 9229 for Node.js) are not being used by other applications.", + ], + }; } diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 963b16c4a..148fe67e3 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -6,6 +6,7 @@ import { AzureWizard, registerEvent, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as path from 'path'; import * as vscode from 'vscode'; +import { CommandAttributes } from '../commands/CommandAttributes'; import { tryGetFunctionProjectRoot } from '../commands/createNewProject/verifyIsProject'; import { PostFuncDebugExecuteStep } from '../commands/debug/PostFuncDebugExecuteStep'; import { localSettingsFileName } from '../constants'; @@ -136,6 +137,7 @@ export function registerFuncHostTaskEvents(): void { if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { const task = runningFuncTaskMap.get(e.execution.task.scope!, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); const wizardContext = Object.assign(context, await createActivityContext()); + wizardContext.activityAttributes = CommandAttributes.Debug; wizardContext.activityTitle = localize('funcTaskEnded', 'Function host task ended.'); const wizard = new AzureWizard(wizardContext, { From e0e5c03fca649fc396054aded24dab03c5dc1e12 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Mon, 1 Dec 2025 09:44:44 -0800 Subject: [PATCH 24/38] WIP for copilot postdebug --- .../debug/PostFuncDebugExecuteStep.ts | 23 +++++++++++++------ src/funcCoreTools/funcHostTask.ts | 9 ++++++-- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/commands/debug/PostFuncDebugExecuteStep.ts b/src/commands/debug/PostFuncDebugExecuteStep.ts index fffa7ca7e..f196ee3dd 100644 --- a/src/commands/debug/PostFuncDebugExecuteStep.ts +++ b/src/commands/debug/PostFuncDebugExecuteStep.ts @@ -3,12 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ActivityChildItem, ActivityChildType, activityInfoContext, AzureWizardExecuteStep, createContextValue, randomUtils, type ExecuteActivityContext, type ExecuteActivityOutput } from "@microsoft/vscode-azext-utils"; +import { ActivityChildItem, ActivityChildType, activityInfoContext, AzureWizardExecuteStep, createContextValue, randomUtils, type ExecuteActivityContext, type ExecuteActivityOutput, type IActionContext } from "@microsoft/vscode-azext-utils"; import { stripVTControlCharacters } from "node:util"; +import { ThemeIcon } from "vscode"; -export class PostFuncDebugExecuteStep extends AzureWizardExecuteStep { +export class PostFuncDebugExecuteStep extends AzureWizardExecuteStep { public priority: number = 999; public stepName: string = 'PostFuncDebugExecuteStep'; + // public options: AzureWizardExecuteStepOptions = { + // continueOnFail: true + // } public constructor(readonly logs: string[]) { super(); @@ -32,10 +36,10 @@ export class PostFuncDebugExecuteStep extends } if (errorLogs.length > 0) { - this._logs = stripVTControlCharacters(errorLogs.join('\n')); context.activityAttributes = context.activityAttributes || {}; context.activityAttributes.logs = errorLogs.map(log => { return { content: stripVTControlCharacters(log) }; }); - throw new Error('Function host encountered errors during startup. See logs for details.'); + context.activityChildren = []; + throw new Error('This is from the error in execute'); } return; @@ -48,12 +52,17 @@ export class PostFuncDebugExecuteStep extends public createFailOutput(_context: T): ExecuteActivityOutput { return { item: new ActivityChildItem({ - label: 'Function host encountered errors during debugging. Click to have Copilot help diagnose the issue.', + label: 'Click to have Copilot help diagnose the issue.', id: `${randomUtils.getRandomHexString(8)}-terminateDebugSession-fail`, - activityType: ActivityChildType.Error, + activityType: ActivityChildType.Fail, contextValue: createContextValue([activityInfoContext, 'terminateDebugSessionFail']), + iconPath: new ThemeIcon('sparkle'), // a little trick to remove the description timer on activity children - description: ' ' + description: ' ', + command: { + "command": "azureResourceGroups.askAgentAboutActivityLogItem", + "title": "Ask Copilot", + } }) }; } diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 148fe67e3..70c5b51f8 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -136,7 +136,7 @@ export function registerFuncHostTaskEvents(): void { context.telemetry.suppressIfSuccessful = true; if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { const task = runningFuncTaskMap.get(e.execution.task.scope!, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); - const wizardContext = Object.assign(context, await createActivityContext()); + const wizardContext = Object.assign(context, await createActivityContext({ withChildren: true })); wizardContext.activityAttributes = CommandAttributes.Debug; wizardContext.activityTitle = localize('funcTaskEnded', 'Function host task ended.'); @@ -146,7 +146,12 @@ export function registerFuncHostTaskEvents(): void { promptSteps: [], executeSteps: [new PostFuncDebugExecuteStep(task?.logs ?? [])] }); - await wizard.execute(); + try { + await wizard.execute(); + } catch (error) { + // swallow errors + console.log(error); + } runningFuncTaskMap.delete(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); } }); From adc4bc4f3369979408cf0ad0559d719c7c07947e Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 7 Jan 2026 15:47:18 -0800 Subject: [PATCH 25/38] Merge with main --- src/funcCoreTools/funcHostTask.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 70c5b51f8..7d642fc8e 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -87,7 +87,7 @@ export const onFuncTaskStarted = funcTaskStartedEmitter.event; export const buildPathToWorkspaceFolderMap = new Map(); const defaultFuncPort: string = '7071'; -const funcCommandRegex: RegExp = /(?:^|[\\/])(func(?:\.exe)?)\s+host\s+start$/i +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 funcCommandRegex.test(commandLine || ''); From df16af2153d681184b01a1bf29996a81d265d440 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 7 Jan 2026 15:58:45 -0800 Subject: [PATCH 26/38] Fix linter --- src/commands/debug/PostFuncDebugExecuteStep.ts | 1 + src/funcCoreTools/funcHostTask.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/debug/PostFuncDebugExecuteStep.ts b/src/commands/debug/PostFuncDebugExecuteStep.ts index f196ee3dd..cc31a945c 100644 --- a/src/commands/debug/PostFuncDebugExecuteStep.ts +++ b/src/commands/debug/PostFuncDebugExecuteStep.ts @@ -20,6 +20,7 @@ export class PostFuncDebugExecuteStep { const errorLogs: string[] = []; + // eslint-disable-next-line no-control-regex const redAnsiRegex = /\x1b\[(?:[0-9;]*31m|[0-9;]*91m|38;5;(9|1)m)/; const functionErrors = [ /No job functions found/i, diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 7d642fc8e..67c31ce45 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureWizard, registerEvent, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzureWizard, nonNullValue, registerEvent, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as path from 'path'; import * as vscode from 'vscode'; import { CommandAttributes } from '../commands/CommandAttributes'; @@ -135,7 +135,8 @@ export function registerFuncHostTaskEvents(): void { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { - const task = runningFuncTaskMap.get(e.execution.task.scope!, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); + const scope = nonNullValue(e.execution.task.scope); + const task = runningFuncTaskMap.get(scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); const wizardContext = Object.assign(context, await createActivityContext({ withChildren: true })); wizardContext.activityAttributes = CommandAttributes.Debug; wizardContext.activityTitle = localize('funcTaskEnded', 'Function host task ended.'); From 8707910c35dcf4c4771a3d25130dadde218ee56a Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 8 Jan 2026 15:44:17 -0800 Subject: [PATCH 27/38] MVP for func debugger view --- extension.bundle.ts | 1 + package.json | 85 +++++++ package.nls.json | 8 +- .../debug/PostFuncDebugExecuteStep.ts | 70 ------ src/debug/FunctionHostDebugView.ts | 229 ++++++++++++++++++ src/extension.ts | 3 + src/funcCoreTools/funcHostErrorUtils.ts | 117 +++++++++ src/funcCoreTools/funcHostTask.ts | 124 ++++++++-- src/utils/ansiUtils.ts | 50 ++++ src/utils/copilotChat.ts | 45 ++++ test/ansiUtils.test.ts | 24 ++ test/funcHostErrorContext.test.ts | 48 ++++ 12 files changed, 709 insertions(+), 95 deletions(-) delete mode 100644 src/commands/debug/PostFuncDebugExecuteStep.ts create mode 100644 src/debug/FunctionHostDebugView.ts create mode 100644 src/funcCoreTools/funcHostErrorUtils.ts create mode 100644 src/utils/ansiUtils.ts create mode 100644 src/utils/copilotChat.ts create mode 100644 test/ansiUtils.test.ts create mode 100644 test/funcHostErrorContext.test.ts diff --git a/extension.bundle.ts b/extension.bundle.ts index 8c4c823f2..07c2300a0 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -40,6 +40,7 @@ export * from './src/templates/IFunctionTemplate'; export * from './src/templates/script/getScriptResourcesLanguage'; export * from './src/templates/TemplateProviderBase'; export * from './src/tree/AzureAccountTreeItemWithProjects'; +export { stripAnsiControlCharacters } from './src/utils/ansiUtils'; export * from './src/utils/cpUtils'; export * from './src/utils/delay'; export * from './src/utils/envUtils'; diff --git a/package.json b/package.json index 9aef75313..9c6bf0581 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,16 @@ ] } }, + "views": { + "debug": [ + { + "id": "azureFunctions.funcHostDebugView", + "name": "%azureFunctions.funcHostDebugView.title%", + "when": "!virtualWorkspace && azureFunctions.funcHostDebugVisible", + "icon": "resources/azure-functions.svg" + } + ] + }, "commands": [ { "command": "azureFunctions.addBinding", @@ -467,6 +477,36 @@ "command": "azureFunctions.getMcpHostKey", "title": "%azureFunctions.getMcpHostKey%", "category": "Azure Functions" + }, + { + "command": "azureFunctions.funcHostDebug.refresh", + "title": "%azureFunctions.funcHostDebug.refresh%", + "category": "Azure Functions", + "icon": "$(refresh)" + }, + { + "command": "azureFunctions.funcHostDebug.showRecentLogs", + "title": "%azureFunctions.funcHostDebug.showRecentLogs%", + "category": "Azure Functions", + "icon": "$(output)" + }, + { + "command": "azureFunctions.funcHostDebug.copyRecentLogs", + "title": "%azureFunctions.funcHostDebug.copyRecentLogs%", + "category": "Azure Functions", + "icon": "$(copy)" + }, + { + "command": "azureFunctions.funcHostDebug.stop", + "title": "%azureFunctions.funcHostDebug.stop%", + "category": "Azure Functions", + "icon": "$(debug-stop)" + }, + { + "command": "azureFunctions.funcHostDebug.askCopilot", + "title": "%azureFunctions.funcHostDebug.askCopilot%", + "category": "Azure Functions", + "icon": "$(sparkle)" } ], "submenus": [ @@ -503,9 +543,34 @@ "submenu": "azureFunctions.submenus.workspaceActions", "when": "view == azureWorkspace", "group": "navigation@1" + }, + { + "command": "azureFunctions.funcHostDebug.refresh", + "when": "view == azureFunctions.funcHostDebugView", + "group": "navigation@1" } ], "view/item/context": [ + { + "command": "azureFunctions.funcHostDebug.showRecentLogs", + "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostTask", + "group": "inline" + }, + { + "command": "azureFunctions.funcHostDebug.copyRecentLogs", + "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostTask", + "group": "inline" + }, + { + "command": "azureFunctions.funcHostDebug.stop", + "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostTask", + "group": "inline" + }, + { + "command": "azureFunctions.funcHostDebug.askCopilot", + "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostError", + "group": "1@1" + }, { "command": "azureFunctions.createFunction", "when": "view == azureWorkspace && viewItem =~ /azFuncLocalProject/i", @@ -947,6 +1012,26 @@ { "command": "azureFunctions.unassignManagedIdentity", "when": "never" + }, + { + "command": "azureFunctions.funcHostDebug.refresh", + "when": "never" + }, + { + "command": "azureFunctions.funcHostDebug.showRecentLogs", + "when": "never" + }, + { + "command": "azureFunctions.funcHostDebug.copyRecentLogs", + "when": "never" + }, + { + "command": "azureFunctions.funcHostDebug.stop", + "when": "never" + }, + { + "command": "azureFunctions.funcHostDebug.askCopilot", + "when": "never" } ], "editor/context": [ diff --git a/package.nls.json b/package.nls.json index d53bd4540..cf1b735ca 100644 --- a/package.nls.json +++ b/package.nls.json @@ -144,5 +144,11 @@ "azureFunctions.mcpProjectType": "The type of MCP integration the project uses.", "azureFunctions.mcpProjectType.NoMcpServer": "Runs the standard Azure Functions runtime with no MCP integration.", "azureFunctions.mcpProjectType.McpExtensionServer": "Runs the Functions host with an embedded MCP server provided by the Azure Functions MCP extension.", - "azureFunctions.mcpProjectType.SelfHostedMcpServer": "Runs the Functions host in custom-handler mode, forwarding requests to a self-hosted MCP server process." + "azureFunctions.mcpProjectType.SelfHostedMcpServer": "Runs the Functions host in custom-handler mode, forwarding requests to a self-hosted MCP server process.", + "azureFunctions.funcHostDebugView.title": "Function Host Debug", + "azureFunctions.funcHostDebug.refresh": "Refresh", + "azureFunctions.funcHostDebug.showRecentLogs": "Show Recent Host Logs", + "azureFunctions.funcHostDebug.copyRecentLogs": "Copy Recent Host Logs", + "azureFunctions.funcHostDebug.stop": "Stop Function Host", + "azureFunctions.funcHostDebug.askCopilot": "Ask Copilot" } diff --git a/src/commands/debug/PostFuncDebugExecuteStep.ts b/src/commands/debug/PostFuncDebugExecuteStep.ts deleted file mode 100644 index cc31a945c..000000000 --- a/src/commands/debug/PostFuncDebugExecuteStep.ts +++ /dev/null @@ -1,70 +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 { ActivityChildItem, ActivityChildType, activityInfoContext, AzureWizardExecuteStep, createContextValue, randomUtils, type ExecuteActivityContext, type ExecuteActivityOutput, type IActionContext } from "@microsoft/vscode-azext-utils"; -import { stripVTControlCharacters } from "node:util"; -import { ThemeIcon } from "vscode"; - -export class PostFuncDebugExecuteStep extends AzureWizardExecuteStep { - public priority: number = 999; - public stepName: string = 'PostFuncDebugExecuteStep'; - // public options: AzureWizardExecuteStepOptions = { - // continueOnFail: true - // } - - public constructor(readonly logs: string[]) { - super(); - } - - public async execute(context: T): Promise { - const errorLogs: string[] = []; - // eslint-disable-next-line no-control-regex - const redAnsiRegex = /\x1b\[(?:[0-9;]*31m|[0-9;]*91m|38;5;(9|1)m)/; - const functionErrors = [ - /No job functions found/i, - /Worker was unable to load entry point/i, - /SyntaxError:/i, - /Cannot find module/i, - /Failed to start Worker Channel/i, - /Serialization and deserialization.*not supported/i - ]; - for (const log of this.logs) { - if (redAnsiRegex.test(log) || functionErrors.some(err => err.test(log))) { - errorLogs.push(log); - } - } - - if (errorLogs.length > 0) { - context.activityAttributes = context.activityAttributes || {}; - context.activityAttributes.logs = errorLogs.map(log => { return { content: stripVTControlCharacters(log) }; }); - context.activityChildren = []; - throw new Error('This is from the error in execute'); - } - - return; - } - - public shouldExecute(_context: T): boolean { - return true; - } - - public createFailOutput(_context: T): ExecuteActivityOutput { - return { - item: new ActivityChildItem({ - label: 'Click to have Copilot help diagnose the issue.', - id: `${randomUtils.getRandomHexString(8)}-terminateDebugSession-fail`, - activityType: ActivityChildType.Fail, - contextValue: createContextValue([activityInfoContext, 'terminateDebugSessionFail']), - iconPath: new ThemeIcon('sparkle'), - // a little trick to remove the description timer on activity children - description: ' ', - command: { - "command": "azureResourceGroups.askAgentAboutActivityLogItem", - "title": "Ask Copilot", - } - }) - }; - } -} diff --git a/src/debug/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts new file mode 100644 index 000000000..afd72dca6 --- /dev/null +++ b/src/debug/FunctionHostDebugView.ts @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerCommand, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { extractFuncHostErrorContextForErrorMessage } from '../funcCoreTools/funcHostErrorUtils'; +import { onRunningFuncTasksChanged, refreshFuncHostDebugContext, runningFuncTaskMap, stopFuncTaskIfRunning, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; +import { localize } from '../localize'; +import { stripAnsiControlCharacters } from '../utils/ansiUtils'; +import { openCopilotChat } from '../utils/copilotChat'; + +const viewId = 'azureFunctions.funcHostDebugView'; + +enum FuncHostDebugContextValue { + HostTask = 'azFunc.funcHostDebug.hostTask', + HostError = 'azFunc.funcHostDebug.hostError', +} + +type FuncHostDebugNode = INoHostNode | IHostTaskNode | IHostErrorNode; + +interface INoHostNode { + kind: 'noHost'; +} + +interface IHostTaskNode { + kind: 'hostTask'; + workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope; + cwd?: string; + portNumber: string; +} + +interface IHostErrorNode { + kind: 'hostError'; + workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope; + cwd?: string; + portNumber: string; + message: string; +} + +export class FuncHostDebugViewProvider implements vscode.TreeDataProvider { + private readonly _onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + public readonly onDidChangeTreeData = this._onDidChangeTreeDataEmitter.event; + + public refresh(): void { + this._onDidChangeTreeDataEmitter.fire(undefined); + } + + public getTreeItem(element: FuncHostDebugNode): vscode.TreeItem { + if (element.kind === 'noHost') { + const item = new vscode.TreeItem(localize('funcHostDebug.noneRunning', 'No Function Host task is currently running.'), vscode.TreeItemCollapsibleState.None); + item.description = localize('funcHostDebug.startDebuggingHint', 'Start debugging (F5) to launch the host.'); + item.iconPath = new vscode.ThemeIcon('debug'); + return item; + } + + if (element.kind === 'hostError') { + const firstLine = element.message.split(/\r?\n/)[0].trim(); + const label = firstLine || localize('funcHostDebug.errorDetected', 'Error detected'); + + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); + item.iconPath = new vscode.ThemeIcon('error'); + item.tooltip = element.message; + item.contextValue = FuncHostDebugContextValue.HostError; + return item; + } + + const task = runningFuncTaskMap.get(element.workspaceFolder, element.cwd); + const scopeLabel = typeof element.workspaceFolder === 'object' + ? element.workspaceFolder.name + : localize('funcHostDebug.globalScope', 'Global'); + + const label = localize('funcHostDebug.hostLabel', 'Function Host ({0})', element.portNumber); + + const tooltip = new vscode.MarkdownString(undefined, true); + tooltip.appendMarkdown(`**${label}**\n\n`); + tooltip.appendMarkdown(`- ${localize('funcHostDebug.workspace', 'Workspace')}: ${scopeLabel}\n`); + tooltip.appendMarkdown(`- ${localize('funcHostDebug.pid', 'PID')}: ${task?.processId ?? localize('funcHostDebug.unknown', 'Unknown')}\n`); + tooltip.appendMarkdown(`- ${localize('funcHostDebug.port', 'Port')}: ${element.portNumber}\n`); + if (element.cwd) { + tooltip.appendMarkdown(`- ${localize('funcHostDebug.cwd', 'CWD')}: ${element.cwd}\n`); + } + + const hasErrors = (task?.errorLogs?.length ?? 0) > 0; + const item = new vscode.TreeItem(label, hasErrors ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None); + item.description = scopeLabel; + item.tooltip = tooltip; + item.contextValue = FuncHostDebugContextValue.HostTask; + item.iconPath = new vscode.ThemeIcon('server-process'); + return item; + } + + public async getChildren(element?: FuncHostDebugNode): Promise { + if (element?.kind === 'hostTask') { + const task = runningFuncTaskMap.get(element.workspaceFolder, element.cwd); + const errors = task?.errorLogs ?? []; + // Show most recent errors first. + return errors + .slice() + .reverse() + .map((message): IHostErrorNode => ({ + kind: 'hostError', + workspaceFolder: element.workspaceFolder, + cwd: element.cwd, + portNumber: element.portNumber, + message, + })); + } else if (element) { + return []; + } + + const hostTasks: IHostTaskNode[] = []; + + for (const folder of vscode.workspace.workspaceFolders ?? []) { + for (const t of runningFuncTaskMap.getAll(folder)) { + if (!t) { + continue; + } + const cwd = (t.taskExecution.task.execution as vscode.ShellExecution | undefined)?.options?.cwd; + hostTasks.push({ kind: 'hostTask', workspaceFolder: folder, cwd, portNumber: t.portNumber }); + } + } + + for (const t of runningFuncTaskMap.getAll(vscode.TaskScope.Global)) { + if (!t) { + continue; + } + const cwd = (t.taskExecution.task.execution as vscode.ShellExecution | undefined)?.options?.cwd; + hostTasks.push({ kind: 'hostTask', workspaceFolder: vscode.TaskScope.Global, cwd, portNumber: t.portNumber }); + } + + if (hostTasks.length === 0) { + return [{ kind: 'noHost' }]; + } + + return hostTasks; + } +} + +function getRecentLogs(task: IRunningFuncTask | undefined, limit: number = 250): string { + const logs = task?.logs ?? []; + const recent = logs.slice(Math.max(0, logs.length - limit)); + return recent.join(''); +} + +function getRecentLogsPlainText(task: IRunningFuncTask | undefined, limit: number = 250): string { + return stripAnsiControlCharacters(getRecentLogs(task, limit)); +} + +function getErrorContextForCopilot(task: IRunningFuncTask | undefined, errorMessage: string): string { + const logs = task?.logs ?? []; + const extracted = extractFuncHostErrorContextForErrorMessage(logs, errorMessage, { before: 5, after: 25, max: 250 }); + const plainLines = extracted.map((l) => stripAnsiControlCharacters(l)); + return plainLines.join('').trim(); +} + +export function registerFunctionHostDebugView(context: vscode.ExtensionContext): void { + const provider = new FuncHostDebugViewProvider(); + + context.subscriptions.push( + vscode.window.registerTreeDataProvider(viewId, provider), + onRunningFuncTasksChanged(() => provider.refresh()), + ); + + // Ensure the context key is correct on activation. + void refreshFuncHostDebugContext(); + + registerCommand('azureFunctions.funcHostDebug.stop', async (actionContext: IActionContext, args: IHostTaskNode) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + await stopFuncTaskIfRunning(args.workspaceFolder, args.cwd, false, false); + }); + + registerCommand('azureFunctions.funcHostDebug.terminate', async (actionContext: IActionContext, args: IHostTaskNode) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + await stopFuncTaskIfRunning(args.workspaceFolder, args.cwd, false, true); + }); + + registerCommand('azureFunctions.funcHostDebug.copyRecentLogs', async (actionContext: IActionContext, args: IHostTaskNode) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); + const text = getRecentLogsPlainText(task); + await vscode.env.clipboard.writeText(text); + vscode.window.setStatusBarMessage(localize('funcHostDebug.copiedLogs', 'Copied recent Function Host logs to clipboard.'), 3000); + }); + + registerCommand('azureFunctions.funcHostDebug.showRecentLogs', async (actionContext: IActionContext, args: IHostTaskNode) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); + const text = getRecentLogsPlainText(task); + + const doc = await vscode.workspace.openTextDocument({ + content: text || localize('funcHostDebug.noLogs', 'No logs captured yet.'), + language: 'log', + }); + await vscode.window.showTextDocument(doc, { preview: true }); + }); + + registerCommand('azureFunctions.funcHostDebug.askCopilot', async (actionContext: IActionContext, args: IHostErrorNode) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); + + const errorContext = getErrorContextForCopilot(task, args.message) || args.message; + + const scopeLabel = typeof args.workspaceFolder === 'object' + ? args.workspaceFolder.name + : localize('funcHostDebug.globalScope', 'Global'); + + const prompt = [ + 'I am debugging an Azure Functions project locally in VS Code.', + `Function Host Port: ${args.portNumber}`, + `Workspace: ${scopeLabel}`, + args.cwd ? `CWD: ${args.cwd}` : undefined, + '', + 'The Functions host produced an error. Diagnose the likely cause and suggest concrete next steps to fix it.', + '', + 'Error output (with surrounding context):', + errorContext, + ].filter((l): l is string => Boolean(l)).join('\n'); + + await openCopilotChat(prompt); + }); + + registerCommand('azureFunctions.funcHostDebug.refresh', async (actionContext: IActionContext) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + provider.refresh(); + await refreshFuncHostDebugContext(); + }); +} diff --git a/src/extension.ts b/src/extension.ts index b8eb3e5e0..816e1f7c8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,6 +22,7 @@ import { registerCommands } from './commands/registerCommands'; import { func } from './constants'; import { BallerinaDebugProvider } from './debug/BallerinaDebugProvider'; import { FuncTaskProvider } from './debug/FuncTaskProvider'; +import { registerFunctionHostDebugView } from './debug/FunctionHostDebugView'; import { JavaDebugProvider } from './debug/JavaDebugProvider'; import { NodeDebugProvider } from './debug/NodeDebugProvider'; import { PowerShellDebugProvider } from './debug/PowerShellDebugProvider'; @@ -94,6 +95,8 @@ export async function activateInternal(context: vscode.ExtensionContext, perfSta registerFuncHostTaskEvents(); + registerFunctionHostDebugView(context); + const nodeDebugProvider: NodeDebugProvider = new NodeDebugProvider(); const pythonDebugProvider: PythonDebugProvider = new PythonDebugProvider(); const javaDebugProvider: JavaDebugProvider = new JavaDebugProvider(); diff --git a/src/funcCoreTools/funcHostErrorUtils.ts b/src/funcCoreTools/funcHostErrorUtils.ts new file mode 100644 index 000000000..5d701997d --- /dev/null +++ b/src/funcCoreTools/funcHostErrorUtils.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { stripAnsiControlCharacters } from '../utils/ansiUtils'; + +export interface FuncHostErrorContextOptions { + /** + * Number of log lines to include before an error line + */ + before?: number; + /** + * Number of log lines to include after an error line + */ + after?: number; + /** + * Maximum number of log lines to return (keeps the most recent) + */ + max?: number; +} + +// eslint-disable-next-line no-control-regex +const redAnsiRegex = /\u001b\[(?:[0-9;]*31m|[0-9;]*91m|38;5;(9|1)m)/; + +export function isFuncHostErrorLog(log: string): boolean { + return redAnsiRegex.test(log); +} + +/** + * Extracts likely error output from the function host log stream, including a small window + * of surrounding context to help diagnose issues (e.g., stack traces that may not be red). + */ +export function extractFuncHostErrorContext(logs: readonly string[], options?: FuncHostErrorContextOptions): string[] { + const before = options?.before ?? 5; + const after = options?.after ?? 15; + const max = options?.max ?? 250; + + const includeIndices = new Set(); + for (let i = 0; i < logs.length; i++) { + if (isFuncHostErrorLog(logs[i])) { + const start = Math.max(0, i - before); + const end = Math.min(logs.length - 1, i + after); + for (let j = start; j <= end; j++) { + includeIndices.add(j); + } + } + } + + // Preserve order + const result: string[] = []; + for (let i = 0; i < logs.length; i++) { + if (includeIndices.has(i)) { + result.push(logs[i]); + } + } + + // Keep most recent `max` lines + if (result.length > max) { + return result.slice(result.length - max); + } + + return result; +} + +/** + * Extracts context for only a single relevant error line (as selected in the UI). + * + * @param errorMessage A plain-text error line (ANSI/control chars already removed). + */ +export function extractFuncHostErrorContextForErrorMessage( + logs: readonly string[], + errorMessage: string, + options?: FuncHostErrorContextOptions +): string[] { + const target = (errorMessage ?? '').trim(); + if (!target) { + return []; + } + + const max = options?.max ?? 250; + + let bestIndex = -1; + let bestScore = 0; + + for (let i = 0; i < logs.length; i++) { + const line = logs[i]; + if (!isFuncHostErrorLog(line)) { + continue; + } + + const plain = stripAnsiControlCharacters(line).trim(); + if (!plain) { + continue; + } + + let score = 0; + if (plain === target) { + score = 2; + } else if (plain.includes(target) || target.includes(plain)) { + score = 1; + } + + if (score > 0 && (score > bestScore || (score === bestScore && i > bestIndex))) { + bestScore = score; + bestIndex = i; + } + } + + if (bestIndex < 0) { + return []; + } + + // Only include the relevant error line (no extra surrounding context). + const result = [logs[bestIndex]]; + return result.length > max ? result.slice(result.length - max) : result; +} diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 67c31ce45..10a35e4f1 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -3,18 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureWizard, nonNullValue, registerEvent, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { registerEvent, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as path from 'path'; import * as vscode from 'vscode'; -import { CommandAttributes } from '../commands/CommandAttributes'; 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 { stripAnsiControlCharacters } from '../utils/ansiUtils'; import { cpUtils } from '../utils/cpUtils'; import { getWorkspaceSetting } from '../vsCodeConfig/settings'; +import { isFuncHostErrorLog } from './funcHostErrorUtils'; export interface IRunningFuncTask { taskExecution: vscode.TaskExecution; @@ -23,6 +22,40 @@ export interface IRunningFuncTask { // stream for reading `func host start` output stream: AsyncIterable | undefined; logs: string[]; + /** + * A small set of recent error lines detected in the host output. + * Used by the Function Host Debug view to surface errors under the host node. + */ + errorLogs?: string[]; + /** + * Tracks whether we've already surfaced an activity log item for errors while this task is still running. + */ + hasReportedLiveErrors?: boolean; +} + +function addErrorLog(task: IRunningFuncTask, rawChunk: string): void { + const plain = stripAnsiControlCharacters(rawChunk).trim(); + if (!plain) { + return; + } + + const arr = task.errorLogs ?? (task.errorLogs = []); + if (arr[arr.length - 1] === plain) { + return; + } + + arr.push(plain); + + // Keep the most recent few to avoid unbounded memory usage. + const maxErrors = 10; + if (arr.length > maxErrors) { + task.errorLogs = arr.slice(arr.length - maxErrors); + } +} + +export interface IRunningFuncTaskWithScope { + scope: vscode.WorkspaceFolder | vscode.TaskScope; + task: IRunningFuncTask; } interface DotnetDebugDebugConfiguration extends vscode.DebugConfiguration { @@ -84,6 +117,38 @@ export const runningFuncTaskMap: RunningFunctionTaskMap = new RunningFunctionTas const funcTaskStartedEmitter = new vscode.EventEmitter<{ scope: vscode.WorkspaceFolder | vscode.TaskScope, execution?: vscode.ShellExecution }>(); export const onFuncTaskStarted = funcTaskStartedEmitter.event; +const runningFuncTasksChangedEmitter = new vscode.EventEmitter(); +export const onRunningFuncTasksChanged = runningFuncTasksChangedEmitter.event; + +const funcHostDebugContextKey = 'azureFunctions.funcHostDebugVisible'; + +function getAllRunningFuncTasks(): IRunningFuncTaskWithScope[] { + const tasks: IRunningFuncTaskWithScope[] = []; + for (const folder of vscode.workspace.workspaceFolders ?? []) { + for (const t of runningFuncTaskMap.getAll(folder)) { + if (t) { + tasks.push({ scope: folder, task: t }); + } + } + } + + // Also include tasks started with global scope + for (const t of runningFuncTaskMap.getAll(vscode.TaskScope.Global)) { + if (t) { + tasks.push({ scope: vscode.TaskScope.Global, task: t }); + } + } + return tasks; +} + +async function updateFuncHostDebugContext(): Promise { + await vscode.commands.executeCommand('setContext', funcHostDebugContextKey, getAllRunningFuncTasks().length > 0); +} + +export async function refreshFuncHostDebugContext(): Promise { + await updateFuncHostDebugContext(); +} + export const buildPathToWorkspaceFolderMap = new Map(); const defaultFuncPort: string = '7071'; @@ -123,11 +188,16 @@ export function registerFuncHostTaskEvents(): void { taskExecution: e.execution, portNumber, stream: latestTerminalShellExecutionEvent?.execution.read(), - logs + logs, + errorLogs: [], + hasReportedLiveErrors: false }; runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask); funcTaskStartedEmitter.fire({ scope: e.execution.task.scope, execution: e.execution.task.execution as vscode.ShellExecution }); + + runningFuncTasksChangedEmitter.fire(); + await updateFuncHostDebugContext(); } }); @@ -135,29 +205,22 @@ export function registerFuncHostTaskEvents(): void { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { - const scope = nonNullValue(e.execution.task.scope); - const task = runningFuncTaskMap.get(scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); - const wizardContext = Object.assign(context, await createActivityContext({ withChildren: true })); - wizardContext.activityAttributes = CommandAttributes.Debug; - wizardContext.activityTitle = localize('funcTaskEnded', 'Function host task ended.'); - - const wizard = new AzureWizard(wizardContext, { - title: localize('funcTaskEnded', 'Function host task ended.'), - - promptSteps: [], - executeSteps: [new PostFuncDebugExecuteStep(task?.logs ?? [])] - }); - try { - await wizard.execute(); - } catch (error) { - // swallow errors - console.log(error); - } runningFuncTaskMap.delete(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); + + runningFuncTasksChangedEmitter.fire(); + await updateFuncHostDebugContext(); } }); - onFuncTaskStarted(async ({ scope, execution }) => { + registerEvent('azureFunctions.onFuncTaskStarted', onFuncTaskStarted, async ( + context: IActionContext, + event: { scope: vscode.WorkspaceFolder | vscode.TaskScope; execution?: vscode.ShellExecution } + ) => { + context.errorHandling.suppressDisplay = true; + context.telemetry.suppressIfSuccessful = true; + + const { scope, execution } = event; + const task = runningFuncTaskMap.get(scope, execution?.options?.cwd); if (!task) { return; @@ -165,6 +228,16 @@ export function registerFuncHostTaskEvents(): void { for await (const chunk of task.stream ?? []) { task.logs.push(chunk); + + // Keep track of errors for the Debug view. + if (isFuncHostErrorLog(chunk)) { + const beforeCount = task.errorLogs?.length ?? 0; + addErrorLog(task, chunk); + const afterCount = task.errorLogs?.length ?? 0; + if (afterCount > beforeCount) { + runningFuncTasksChangedEmitter.fire(); + } + } } }); @@ -223,6 +296,9 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol if (killAll) { runningFuncTaskMap.delete(workspaceFolder); } + + runningFuncTasksChangedEmitter.fire(); + await updateFuncHostDebugContext(); } /** diff --git a/src/utils/ansiUtils.ts b/src/utils/ansiUtils.ts new file mode 100644 index 000000000..ba330a285 --- /dev/null +++ b/src/utils/ansiUtils.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Removes ANSI escape sequences and other terminal control characters from a string. + * + * This is intended for presenting Function Host output in a plain text editor / clipboard. + */ +export function stripAnsiControlCharacters(text: string): string { + if (!text) { + return text; + } + + // OSC (Operating System Command) sequences, e.g. "ESC ] 0 ; title BEL" or terminated by "ESC \\" + // Also supports the single-character C1 control alternative (0x9D). + // eslint-disable-next-line no-control-regex + const oscRegex = /(?:\u001B\]|\u009D)[\s\S]*?(?:\u0007|\u001B\\)/g; + + // DCS/PM/APC string sequences, terminated by ST ("ESC \\"). + // - DCS: ESC P ... ESC \\ (C1 alternative: 0x90) + // - PM: ESC ^ ... ESC \\ (C1 alternative: 0x9E) + // - APC: ESC _ ... ESC \\ (C1 alternative: 0x9F) + // eslint-disable-next-line no-control-regex + const stringTerminatedRegex = /(?:\u001B[P^_]|[\u0090\u009E\u009F])[\s\S]*?\u001B\\/g; + + // Most CSI + single ESC sequences (covers common color codes, cursor movement, etc.) + // This pattern is derived from common community implementations (e.g. "ansi-regex") but + // kept local to avoid adding a dependency for a single utility. + // eslint-disable-next-line no-control-regex + const ansiRegex = /[\u001B\u009B][[\]()#;?]*(?:(?:\d{1,4})(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + + let result = text; + result = result.replace(oscRegex, ''); + result = result.replace(stringTerminatedRegex, ''); + result = result.replace(ansiRegex, ''); + + // Remove remaining C0 control characters (except tab, newline, carriage return) + DEL. + // These can slip in from terminal output streams. + result = Array.from(result) + .filter((ch) => { + const code = ch.charCodeAt(0); + // Preserve TAB (9), LF (10), CR (13). Strip other C0 controls and DEL. + return code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127); + }) + .join(''); + + return result; +} diff --git a/src/utils/copilotChat.ts b/src/utils/copilotChat.ts new file mode 100644 index 000000000..90522722f --- /dev/null +++ b/src/utils/copilotChat.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +import { localize } from '../localize'; + +/** + * Best-effort helper to open GitHub Copilot Chat with a pre-filled prompt. + * + * VS Code command IDs and argument shapes have evolved over time, so we try a few. + */ +export async function openCopilotChat(prompt: string): Promise { + const trimmed = (prompt ?? '').trim(); + if (!trimmed) { + return; + } + + const candidates: Array<{ command: string; args: unknown[] }> = [ + // Newer VS Code variants + { command: 'workbench.action.chat.open', args: [trimmed] }, + { command: 'workbench.action.chat.open', args: [{ query: trimmed }] }, + // Older / alternate variants + { command: 'workbench.action.openChat', args: [trimmed] }, + // Copilot extensions (IDs vary by version) + { command: 'github.copilot.openChat', args: [trimmed] }, + { command: 'github.copilot-chat.openChat', args: [trimmed] }, + ]; + + for (const { command, args } of candidates) { + try { + await vscode.commands.executeCommand(command, ...args); + return; + } catch { + // Ignore and try the next candidate + } + } + + void vscode.window.showWarningMessage(localize( + 'funcHostDebug.copilotChatUnavailable', + 'Unable to open Copilot Chat. Please ensure GitHub Copilot Chat is installed and enabled.' + )); +} diff --git a/test/ansiUtils.test.ts b/test/ansiUtils.test.ts new file mode 100644 index 000000000..e5ab36f32 --- /dev/null +++ b/test/ansiUtils.test.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { stripAnsiControlCharacters } from '../extension.bundle'; + +suite('stripAnsiControlCharacters', () => { + test('removes CSI color sequences', () => { + const input = '\u001b[31mred\u001b[39m'; + assert.strictEqual(stripAnsiControlCharacters(input), 'red'); + }); + + test('removes OSC sequences', () => { + const input = '\u001b]0;my title\u0007hello'; + assert.strictEqual(stripAnsiControlCharacters(input), 'hello'); + }); + + test('preserves newlines and tabs while removing other control chars', () => { + const input = `a\n\t\u0000b\r\n\u001b[2Kc`; + assert.strictEqual(stripAnsiControlCharacters(input), 'a\n\tb\r\nc'); + }); +}); diff --git a/test/funcHostErrorContext.test.ts b/test/funcHostErrorContext.test.ts new file mode 100644 index 000000000..0d6998955 --- /dev/null +++ b/test/funcHostErrorContext.test.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; + +// eslint-disable-next-line no-restricted-imports +import { extractFuncHostErrorContext, extractFuncHostErrorContextForErrorMessage, isFuncHostErrorLog } from '../src/funcCoreTools/funcHostErrorUtils'; + +suite('Function host error context extraction', () => { + test('detects red ANSI as error', () => { + assert.strictEqual(isFuncHostErrorLog('\u001b[31m[Error] Boom\u001b[39m'), true); + assert.strictEqual(isFuncHostErrorLog('normal log line'), false); + }); + + test('extractFuncHostErrorContext includes surrounding window', () => { + const logs = [ + 'line 0\n', + 'line 1\n', + '\u001b[31m[Error] Something failed\u001b[39m\n', + 'line 3\n', + 'line 4\n', + ]; + + const extracted = extractFuncHostErrorContext(logs, { before: 1, after: 1, max: 250 }); + assert.deepStrictEqual(extracted, [ + 'line 1\n', + '\u001b[31m[Error] Something failed\u001b[39m\n', + 'line 3\n', + ]); + }); + + test('extractFuncHostErrorContextForErrorMessage only includes the selected error line', () => { + const logs = [ + 'line 0\n', + '\u001b[31m[Error] First\u001b[39m\n', + 'line 2\n', + '\u001b[31m[Error] Second\u001b[39m\n', + 'line 4\n', + ]; + + const extracted = extractFuncHostErrorContextForErrorMessage(logs, '[Error] Second', { before: 1, after: 1, max: 250 }); + assert.deepStrictEqual(extracted, [ + '\u001b[31m[Error] Second\u001b[39m\n', + ]); + }); +}); From 6dff88e23753e2046df5dd897af2b0b23bd50ea7 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Tue, 13 Jan 2026 16:18:44 -0800 Subject: [PATCH 28/38] Setting for debugger view, show first error --- package.json | 16 ++++++++ package.nls.json | 2 + src/debug/FunctionHostDebugView.ts | 53 ++++++++++++++++++++++--- src/funcCoreTools/funcHostErrorUtils.ts | 15 +++++-- src/funcCoreTools/funcHostTask.ts | 18 +++++++-- test/funcHostErrorContext.test.ts | 4 +- test/funcHostErrorGrouping.test.ts | 0 7 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 test/funcHostErrorGrouping.test.ts diff --git a/package.json b/package.json index 9c6bf0581..cc32d8c54 100644 --- a/package.json +++ b/package.json @@ -484,6 +484,12 @@ "category": "Azure Functions", "icon": "$(refresh)" }, + { + "command": "azureFunctions.funcHostDebug.clearErrors", + "title": "%azureFunctions.funcHostDebug.clearErrors%", + "category": "Azure Functions", + "icon": "$(clear-all)" + }, { "command": "azureFunctions.funcHostDebug.showRecentLogs", "title": "%azureFunctions.funcHostDebug.showRecentLogs%", @@ -548,6 +554,11 @@ "command": "azureFunctions.funcHostDebug.refresh", "when": "view == azureFunctions.funcHostDebugView", "group": "navigation@1" + }, + { + "command": "azureFunctions.funcHostDebug.clearErrors", + "when": "view == azureFunctions.funcHostDebugView", + "group": "navigation@1" } ], "view/item/context": [ @@ -1277,6 +1288,11 @@ "description": "%azureFunctions.enableJavaRemoteDebugging%", "default": false }, + "azureFunctions.alwaysShowFuncHostDebugView": { + "type": "boolean", + "description": "%azureFunctions.alwaysShowFuncHostDebugView%", + "default": false + }, "azureFunctions.showProjectWarning": { "type": "boolean", "description": "%azureFunctions.showProjectWarning%", diff --git a/package.nls.json b/package.nls.json index cf1b735ca..a161a8fab 100644 --- a/package.nls.json +++ b/package.nls.json @@ -39,6 +39,7 @@ "azureFunctions.disconnectRepo": "Disconnect from Repo...", "azureFunctions.enableFunction": "Enable Function", "azureFunctions.enableJavaRemoteDebugging": "Enable remote debugging for Java Functions Apps running on Windows. (experimental)", + "azureFunctions.alwaysShowFuncHostDebugView": "Always show the Function Host Debug view in Run and Debug, even when no host task is running.", "azureFunctions.enableOutputTimestamps": "Prepends each line displayed in the output channel with a timestamp.", "azureFunctions.enableRemoteDebugging": "Enable remote debugging for Node.js Function Apps running on Linux App Service plans. Consumption plans are not supported. (experimental)", "azureFunctions.enableSystemIdentity": "Enable System Assigned Identity", @@ -147,6 +148,7 @@ "azureFunctions.mcpProjectType.SelfHostedMcpServer": "Runs the Functions host in custom-handler mode, forwarding requests to a self-hosted MCP server process.", "azureFunctions.funcHostDebugView.title": "Function Host Debug", "azureFunctions.funcHostDebug.refresh": "Refresh", + "azureFunctions.funcHostDebug.clearErrors": "Clear Function Host Errors", "azureFunctions.funcHostDebug.showRecentLogs": "Show Recent Host Logs", "azureFunctions.funcHostDebug.copyRecentLogs": "Copy Recent Host Logs", "azureFunctions.funcHostDebug.stop": "Stop Function Host", diff --git a/src/debug/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts index afd72dca6..0cc8ee000 100644 --- a/src/debug/FunctionHostDebugView.ts +++ b/src/debug/FunctionHostDebugView.ts @@ -11,8 +11,6 @@ import { localize } from '../localize'; import { stripAnsiControlCharacters } from '../utils/ansiUtils'; import { openCopilotChat } from '../utils/copilotChat'; -const viewId = 'azureFunctions.funcHostDebugView'; - enum FuncHostDebugContextValue { HostTask = 'azFunc.funcHostDebug.hostTask', HostError = 'azFunc.funcHostDebug.hostError', @@ -82,8 +80,7 @@ export class FuncHostDebugViewProvider implements vscode.TreeDataProvider 0; - const item = new vscode.TreeItem(label, hasErrors ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None); + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Expanded); item.description = scopeLabel; item.tooltip = tooltip; item.contextValue = FuncHostDebugContextValue.HostTask; @@ -138,6 +135,47 @@ export class FuncHostDebugViewProvider implements vscode.TreeDataProvider { + const newlyErroredTasks: IRunningFuncTask[] = []; + + for (const folder of vscode.workspace.workspaceFolders ?? []) { + for (const t of runningFuncTaskMap.getAll(folder)) { + if (!t) { + continue; + } + + if ((t.errorLogs?.length ?? 0) > 0 && !t.hasReportedLiveErrors) { + newlyErroredTasks.push(t); + } + } + } + + for (const t of runningFuncTaskMap.getAll(vscode.TaskScope.Global)) { + if (!t) { + continue; + } + + if ((t.errorLogs?.length ?? 0) > 0 && !t.hasReportedLiveErrors) { + newlyErroredTasks.push(t); + } + } + + if (newlyErroredTasks.length === 0) { + return; + } + + // Show Run & Debug view (Debug container) so the view (contributed under it) is visible. + try { + await vscode.commands.executeCommand('workbench.view.debug'); + // Mark as revealed only after the view open attempt, to avoid repeated calls. + for (const t of newlyErroredTasks) { + t.hasReportedLiveErrors = true; + } + } catch { + // If this fails, leave flags untouched so we can try again later. + } +} + function getRecentLogs(task: IRunningFuncTask | undefined, limit: number = 250): string { const logs = task?.logs ?? []; const recent = logs.slice(Math.max(0, logs.length - limit)); @@ -159,8 +197,11 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): const provider = new FuncHostDebugViewProvider(); context.subscriptions.push( - vscode.window.registerTreeDataProvider(viewId, provider), - onRunningFuncTasksChanged(() => provider.refresh()), + vscode.window.registerTreeDataProvider('azureFunctions.funcHostDebugView', provider), + onRunningFuncTasksChanged(() => { + provider.refresh(); + void tryOpenDebugViewOnFirstFuncHostError(); + }), ); // Ensure the context key is correct on activation. diff --git a/src/funcCoreTools/funcHostErrorUtils.ts b/src/funcCoreTools/funcHostErrorUtils.ts index 5d701997d..639acdf00 100644 --- a/src/funcCoreTools/funcHostErrorUtils.ts +++ b/src/funcCoreTools/funcHostErrorUtils.ts @@ -78,6 +78,8 @@ export function extractFuncHostErrorContextForErrorMessage( return []; } + const before = options?.before ?? 5; + const after = options?.after ?? 15; const max = options?.max ?? 250; let bestIndex = -1; @@ -111,7 +113,14 @@ export function extractFuncHostErrorContextForErrorMessage( return []; } - // Only include the relevant error line (no extra surrounding context). - const result = [logs[bestIndex]]; - return result.length > max ? result.slice(result.length - max) : result; + const start = Math.max(0, bestIndex - before); + const end = Math.min(logs.length - 1, bestIndex + after); + + const result = logs.slice(start, end + 1); + + if (result.length > max) { + return result.slice(result.length - max); + } + + return result; } diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 10a35e4f1..bfe155626 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -28,7 +28,8 @@ export interface IRunningFuncTask { */ errorLogs?: string[]; /** - * Tracks whether we've already surfaced an activity log item for errors while this task is still running. + * Tracks whether we've already surfaced the "first error" UX for this host session (e.g. opening the Debug view). + * This avoids repeatedly stealing focus / opening the view for every subsequent error. */ hasReportedLiveErrors?: boolean; } @@ -121,6 +122,7 @@ const runningFuncTasksChangedEmitter = new vscode.EventEmitter(); export const onRunningFuncTasksChanged = runningFuncTasksChangedEmitter.event; const funcHostDebugContextKey = 'azureFunctions.funcHostDebugVisible'; +const alwaysShowFuncHostDebugViewSetting = 'alwaysShowFuncHostDebugView'; function getAllRunningFuncTasks(): IRunningFuncTaskWithScope[] { const tasks: IRunningFuncTaskWithScope[] = []; @@ -142,7 +144,8 @@ function getAllRunningFuncTasks(): IRunningFuncTaskWithScope[] { } async function updateFuncHostDebugContext(): Promise { - await vscode.commands.executeCommand('setContext', funcHostDebugContextKey, getAllRunningFuncTasks().length > 0); + const alwaysShow = !!getWorkspaceSetting(alwaysShowFuncHostDebugViewSetting); + await vscode.commands.executeCommand('setContext', funcHostDebugContextKey, alwaysShow || getAllRunningFuncTasks().length > 0); } export async function refreshFuncHostDebugContext(): Promise { @@ -190,7 +193,7 @@ export function registerFuncHostTaskEvents(): void { stream: latestTerminalShellExecutionEvent?.execution.read(), logs, errorLogs: [], - hasReportedLiveErrors: false + hasReportedLiveErrors: false, }; runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask); @@ -201,6 +204,15 @@ export function registerFuncHostTaskEvents(): void { } }); + registerEvent('azureFunctions.onDidChangeConfiguration', vscode.workspace.onDidChangeConfiguration, async (context: IActionContext, e: vscode.ConfigurationChangeEvent) => { + context.errorHandling.suppressDisplay = true; + context.telemetry.suppressIfSuccessful = true; + + if (e.affectsConfiguration(`azureFunctions.${alwaysShowFuncHostDebugViewSetting}`)) { + await updateFuncHostDebugContext(); + } + }); + registerEvent('azureFunctions.onDidEndTask', vscode.tasks.onDidEndTaskProcess, async (context: IActionContext, e: vscode.TaskProcessEndEvent) => { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; diff --git a/test/funcHostErrorContext.test.ts b/test/funcHostErrorContext.test.ts index 0d6998955..67588a450 100644 --- a/test/funcHostErrorContext.test.ts +++ b/test/funcHostErrorContext.test.ts @@ -31,7 +31,7 @@ suite('Function host error context extraction', () => { ]); }); - test('extractFuncHostErrorContextForErrorMessage only includes the selected error line', () => { + test('extractFuncHostErrorContextForErrorMessage only includes the selected error window', () => { const logs = [ 'line 0\n', '\u001b[31m[Error] First\u001b[39m\n', @@ -42,7 +42,9 @@ suite('Function host error context extraction', () => { const extracted = extractFuncHostErrorContextForErrorMessage(logs, '[Error] Second', { before: 1, after: 1, max: 250 }); assert.deepStrictEqual(extracted, [ + 'line 2\n', '\u001b[31m[Error] Second\u001b[39m\n', + 'line 4\n', ]); }); }); diff --git a/test/funcHostErrorGrouping.test.ts b/test/funcHostErrorGrouping.test.ts new file mode 100644 index 000000000..e69de29bb From 412b7515603f23604c455046273d18422b5d9e47 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 14 Jan 2026 13:20:12 -0800 Subject: [PATCH 29/38] Refactoring/cleaning up --- src/commands/CommandAttributes.ts | 9 - src/commands/pickFuncProcess.ts | 72 ++---- src/debug/FunctionHostDebugView.ts | 236 +++++------------- src/debug/registerFunctionHostDebugView.ts | 180 +++++++++++++ src/extension.ts | 3 +- src/funcCoreTools/funcHostTask.ts | 2 +- src/tree/localProject/LocalProjectTreeItem.ts | 1 - test/funcHostErrorContext.test.ts | 12 +- test/funcHostErrorGrouping.test.ts | 33 +++ 9 files changed, 295 insertions(+), 253 deletions(-) create mode 100644 src/debug/registerFunctionHostDebugView.ts diff --git a/src/commands/CommandAttributes.ts b/src/commands/CommandAttributes.ts index c4eaaed96..991e1c3ae 100644 --- a/src/commands/CommandAttributes.ts +++ b/src/commands/CommandAttributes.ts @@ -30,13 +30,4 @@ export class CommandAttributes { "Task hub creation fails — if an existing parent DTS resource was selected, check that the scheduler is not stuck in a provisioning state.", ], }; - - static readonly Debug: ActivityAttributes = { - description: "Starts the Azure Functions host in debug mode, allowing you to set breakpoints and step through your function code locally using a debugger.", - troubleshooting: [ - "Function host fails to start — check the output logs for errors related to your function code or configuration.", - "Breakpoints are not being hit — ensure that the debugger is properly attached and that you're running the function host in debug mode.", - "Port conflicts — verify that the ports required for debugging (e.g., 9229 for Node.js) are not being used by other applications.", - ], - }; } diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index b88cdaf68..058b61673 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -20,9 +20,6 @@ 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, @@ -150,8 +147,6 @@ 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) { @@ -160,41 +155,31 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo const taskInfo: IRunningFuncTask | undefined = runningFuncTaskMap.get(workspaceFolder, buildPath); if (taskInfo) { - 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; + for (const scheme of ['http', 'https']) { + const statusRequest: AzExtRequestPrepareOptions = { url: `${scheme}://localhost:${funcPort}/admin/host/status`, method: 'GET' }; + if (scheme === 'https') { + statusRequest.rejectUnauthorized = false; } - } 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; - } - 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 - } + 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 } } } + } await delay(intervalMs); @@ -206,23 +191,6 @@ 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/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts index 0cc8ee000..74a96497b 100644 --- a/src/debug/FunctionHostDebugView.ts +++ b/src/debug/FunctionHostDebugView.ts @@ -3,13 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { registerCommand, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; -import { extractFuncHostErrorContextForErrorMessage } from '../funcCoreTools/funcHostErrorUtils'; -import { onRunningFuncTasksChanged, refreshFuncHostDebugContext, runningFuncTaskMap, stopFuncTaskIfRunning, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; +import { runningFuncTaskMap } from '../funcCoreTools/funcHostTask'; import { localize } from '../localize'; -import { stripAnsiControlCharacters } from '../utils/ansiUtils'; -import { openCopilotChat } from '../utils/copilotChat'; enum FuncHostDebugContextValue { HostTask = 'azFunc.funcHostDebug.hostTask', @@ -22,14 +18,14 @@ interface INoHostNode { kind: 'noHost'; } -interface IHostTaskNode { +export interface IHostTaskNode { kind: 'hostTask'; workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope; cwd?: string; portNumber: string; } -interface IHostErrorNode { +export interface IHostErrorNode { kind: 'hostError'; workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope; cwd?: string; @@ -37,6 +33,49 @@ interface IHostErrorNode { message: string; } +function getNoHostTreeItem(): vscode.TreeItem { + const item = new vscode.TreeItem(localize('funcHostDebug.noneRunning', 'No Function Host task is currently running.'), vscode.TreeItemCollapsibleState.None); + item.description = localize('funcHostDebug.startDebuggingHint', 'Start debugging (F5) to launch the host.'); + item.iconPath = new vscode.ThemeIcon('debug'); + return item; +} + +function getHostErrorTreeItem(element: IHostErrorNode): vscode.TreeItem { + const firstLine = element.message.split(/\r?\n/)[0].trim(); + const label = firstLine || localize('funcHostDebug.errorDetected', 'Error detected'); + + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); + item.iconPath = new vscode.ThemeIcon('error'); + item.tooltip = element.message; + item.contextValue = FuncHostDebugContextValue.HostError; + return item; +} + +function getHostTaskTreeItem(element: IHostTaskNode): vscode.TreeItem { + const task = runningFuncTaskMap.get(element.workspaceFolder, element.cwd); + const scopeLabel = typeof element.workspaceFolder === 'object' + ? element.workspaceFolder.name + : localize('funcHostDebug.globalScope', 'Global'); + + const label = localize('funcHostDebug.hostLabel', 'Function Host ({0})', element.portNumber); + + const tooltip = new vscode.MarkdownString(undefined, true); + tooltip.appendMarkdown(`**${label}**\n\n`); + tooltip.appendMarkdown(`- ${localize('funcHostDebug.workspace', 'Workspace')}: ${scopeLabel}\n`); + tooltip.appendMarkdown(`- ${localize('funcHostDebug.pid', 'PID')}: ${task?.processId ?? localize('funcHostDebug.unknown', 'Unknown')}\n`); + tooltip.appendMarkdown(`- ${localize('funcHostDebug.port', 'Port')}: ${element.portNumber}\n`); + if (element.cwd) { + tooltip.appendMarkdown(`- ${localize('funcHostDebug.cwd', 'CWD')}: ${element.cwd}\n`); + } + + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Expanded); + item.description = scopeLabel; + item.tooltip = tooltip; + item.contextValue = FuncHostDebugContextValue.HostTask; + item.iconPath = new vscode.ThemeIcon('server-process'); + return item; +} + export class FuncHostDebugViewProvider implements vscode.TreeDataProvider { private readonly _onDidChangeTreeDataEmitter = new vscode.EventEmitter(); public readonly onDidChangeTreeData = this._onDidChangeTreeDataEmitter.event; @@ -46,46 +85,17 @@ export class FuncHostDebugViewProvider implements vscode.TreeDataProvider { @@ -134,137 +144,3 @@ export class FuncHostDebugViewProvider implements vscode.TreeDataProvider { - const newlyErroredTasks: IRunningFuncTask[] = []; - - for (const folder of vscode.workspace.workspaceFolders ?? []) { - for (const t of runningFuncTaskMap.getAll(folder)) { - if (!t) { - continue; - } - - if ((t.errorLogs?.length ?? 0) > 0 && !t.hasReportedLiveErrors) { - newlyErroredTasks.push(t); - } - } - } - - for (const t of runningFuncTaskMap.getAll(vscode.TaskScope.Global)) { - if (!t) { - continue; - } - - if ((t.errorLogs?.length ?? 0) > 0 && !t.hasReportedLiveErrors) { - newlyErroredTasks.push(t); - } - } - - if (newlyErroredTasks.length === 0) { - return; - } - - // Show Run & Debug view (Debug container) so the view (contributed under it) is visible. - try { - await vscode.commands.executeCommand('workbench.view.debug'); - // Mark as revealed only after the view open attempt, to avoid repeated calls. - for (const t of newlyErroredTasks) { - t.hasReportedLiveErrors = true; - } - } catch { - // If this fails, leave flags untouched so we can try again later. - } -} - -function getRecentLogs(task: IRunningFuncTask | undefined, limit: number = 250): string { - const logs = task?.logs ?? []; - const recent = logs.slice(Math.max(0, logs.length - limit)); - return recent.join(''); -} - -function getRecentLogsPlainText(task: IRunningFuncTask | undefined, limit: number = 250): string { - return stripAnsiControlCharacters(getRecentLogs(task, limit)); -} - -function getErrorContextForCopilot(task: IRunningFuncTask | undefined, errorMessage: string): string { - const logs = task?.logs ?? []; - const extracted = extractFuncHostErrorContextForErrorMessage(logs, errorMessage, { before: 5, after: 25, max: 250 }); - const plainLines = extracted.map((l) => stripAnsiControlCharacters(l)); - return plainLines.join('').trim(); -} - -export function registerFunctionHostDebugView(context: vscode.ExtensionContext): void { - const provider = new FuncHostDebugViewProvider(); - - context.subscriptions.push( - vscode.window.registerTreeDataProvider('azureFunctions.funcHostDebugView', provider), - onRunningFuncTasksChanged(() => { - provider.refresh(); - void tryOpenDebugViewOnFirstFuncHostError(); - }), - ); - - // Ensure the context key is correct on activation. - void refreshFuncHostDebugContext(); - - registerCommand('azureFunctions.funcHostDebug.stop', async (actionContext: IActionContext, args: IHostTaskNode) => { - actionContext.telemetry.properties.source = 'funcHostDebugView'; - await stopFuncTaskIfRunning(args.workspaceFolder, args.cwd, false, false); - }); - - registerCommand('azureFunctions.funcHostDebug.terminate', async (actionContext: IActionContext, args: IHostTaskNode) => { - actionContext.telemetry.properties.source = 'funcHostDebugView'; - await stopFuncTaskIfRunning(args.workspaceFolder, args.cwd, false, true); - }); - - registerCommand('azureFunctions.funcHostDebug.copyRecentLogs', async (actionContext: IActionContext, args: IHostTaskNode) => { - actionContext.telemetry.properties.source = 'funcHostDebugView'; - const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); - const text = getRecentLogsPlainText(task); - await vscode.env.clipboard.writeText(text); - vscode.window.setStatusBarMessage(localize('funcHostDebug.copiedLogs', 'Copied recent Function Host logs to clipboard.'), 3000); - }); - - registerCommand('azureFunctions.funcHostDebug.showRecentLogs', async (actionContext: IActionContext, args: IHostTaskNode) => { - actionContext.telemetry.properties.source = 'funcHostDebugView'; - const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); - const text = getRecentLogsPlainText(task); - - const doc = await vscode.workspace.openTextDocument({ - content: text || localize('funcHostDebug.noLogs', 'No logs captured yet.'), - language: 'log', - }); - await vscode.window.showTextDocument(doc, { preview: true }); - }); - - registerCommand('azureFunctions.funcHostDebug.askCopilot', async (actionContext: IActionContext, args: IHostErrorNode) => { - actionContext.telemetry.properties.source = 'funcHostDebugView'; - const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); - - const errorContext = getErrorContextForCopilot(task, args.message) || args.message; - - const scopeLabel = typeof args.workspaceFolder === 'object' - ? args.workspaceFolder.name - : localize('funcHostDebug.globalScope', 'Global'); - - const prompt = [ - 'I am debugging an Azure Functions project locally in VS Code.', - `Function Host Port: ${args.portNumber}`, - `Workspace: ${scopeLabel}`, - args.cwd ? `CWD: ${args.cwd}` : undefined, - '', - 'The Functions host produced an error. Diagnose the likely cause and suggest concrete next steps to fix it.', - '', - 'Error output (with surrounding context):', - errorContext, - ].filter((l): l is string => Boolean(l)).join('\n'); - - await openCopilotChat(prompt); - }); - - registerCommand('azureFunctions.funcHostDebug.refresh', async (actionContext: IActionContext) => { - actionContext.telemetry.properties.source = 'funcHostDebugView'; - provider.refresh(); - await refreshFuncHostDebugContext(); - }); -} diff --git a/src/debug/registerFunctionHostDebugView.ts b/src/debug/registerFunctionHostDebugView.ts new file mode 100644 index 000000000..4ab20d1e2 --- /dev/null +++ b/src/debug/registerFunctionHostDebugView.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerCommand, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { extractFuncHostErrorContextForErrorMessage } from '../funcCoreTools/funcHostErrorUtils'; +import { onRunningFuncTasksChanged, refreshFuncHostDebugContext, runningFuncTaskMap, stopFuncTaskIfRunning, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; +import { localize } from '../localize'; +import { stripAnsiControlCharacters } from '../utils/ansiUtils'; +import { openCopilotChat } from '../utils/copilotChat'; +import { FuncHostDebugViewProvider, type IHostErrorNode, type IHostTaskNode } from './FunctionHostDebugView'; + +const viewId = 'azureFunctions.funcHostDebugView'; + +function isHostTaskNode(node: unknown): node is IHostTaskNode { + if (!node || typeof node !== 'object') { + return false; + } + + const n = node as Partial; + const scope = (n as { workspaceFolder?: unknown }).workspaceFolder; + const hasValidScope = typeof scope === 'object' || typeof scope === 'number'; + + return n.kind === 'hostTask' + && hasValidScope + && typeof n.portNumber === 'string' + && (n.cwd === undefined || typeof n.cwd === 'string'); +} + +function isHostErrorNode(node: unknown): node is IHostErrorNode { + if (!node || typeof node !== 'object') { + return false; + } + + const n = node as Partial; + const scope = (n as { workspaceFolder?: unknown }).workspaceFolder; + const hasValidScope = typeof scope === 'object' || typeof scope === 'number'; + + return n.kind === 'hostError' + && hasValidScope + && typeof n.portNumber === 'string' + && typeof n.message === 'string' + && (n.cwd === undefined || typeof n.cwd === 'string'); +} + +async function tryOpenDebugViewOnFirstFuncHostError(): Promise { + const newlyErroredTasks: IRunningFuncTask[] = []; + for (const folder of vscode.workspace.workspaceFolders ?? []) { + for (const t of runningFuncTaskMap.getAll(folder)) { + if (!t) { + continue; + } + + if ((t.errorLogs?.length ?? 0) > 0 && !t.hasReportedLiveErrors) { + newlyErroredTasks.push(t); + } + } + } + + if (newlyErroredTasks.length === 0) { + return; + } + + // Show Run & Debug view (Debug container) so the view (contributed under it) is visible. + try { + await vscode.commands.executeCommand('workbench.view.debug'); + // Mark as revealed only after the view open attempt, to avoid repeated calls. + for (const t of newlyErroredTasks) { + t.hasReportedLiveErrors = true; + } + } catch { + // If this fails, leave flags untouched so we can try again later. + } +} + +function getRecentLogs(task: IRunningFuncTask | undefined, limit: number = 250): string { + const logs = task?.logs ?? []; + const recent = logs.slice(Math.max(0, logs.length - limit)); + return recent.join(''); +} + +function getRecentLogsPlainText(task: IRunningFuncTask | undefined, limit: number = 250): string { + return stripAnsiControlCharacters(getRecentLogs(task, limit)); +} + +function getErrorContextForCopilot(task: IRunningFuncTask | undefined, errorMessage: string): string { + const logs = task?.logs ?? []; + const extracted = extractFuncHostErrorContextForErrorMessage(logs, errorMessage, { before: 5, after: 25, max: 250 }); + const plainLines = extracted.map((l) => stripAnsiControlCharacters(l)); + return plainLines.join('').trim(); +} + +export function registerFunctionHostDebugView(context: vscode.ExtensionContext): void { + const provider = new FuncHostDebugViewProvider(); + + context.subscriptions.push( + vscode.window.registerTreeDataProvider(viewId, provider), + onRunningFuncTasksChanged(() => { + provider.refresh(); + void tryOpenDebugViewOnFirstFuncHostError(); + }), + ); + + // Ensure the context key is correct on activation. + void refreshFuncHostDebugContext(); + + registerCommand('azureFunctions.funcHostDebug.stop', async (actionContext: IActionContext, args: unknown) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + if (!isHostTaskNode(args)) { + return; + } + + await stopFuncTaskIfRunning(args.workspaceFolder, args.cwd, false, false); + }); + + registerCommand('azureFunctions.funcHostDebug.copyRecentLogs', async (actionContext: IActionContext, args: unknown) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + if (!isHostTaskNode(args)) { + return; + } + + const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); + const text = getRecentLogsPlainText(task); + await vscode.env.clipboard.writeText(text); + vscode.window.setStatusBarMessage(localize('funcHostDebug.copiedLogs', 'Copied recent Function Host logs to clipboard.'), 3000); + }); + + registerCommand('azureFunctions.funcHostDebug.showRecentLogs', async (actionContext: IActionContext, args: unknown) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + if (!isHostTaskNode(args)) { + return; + } + + const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); + const text = getRecentLogsPlainText(task); + + const doc = await vscode.workspace.openTextDocument({ + content: text || localize('funcHostDebug.noLogs', 'No logs captured yet.'), + language: 'log', + }); + await vscode.window.showTextDocument(doc, { preview: true }); + }); + + registerCommand('azureFunctions.funcHostDebug.askCopilot', async (actionContext: IActionContext, args: unknown) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + if (!isHostErrorNode(args)) { + return; + } + + const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); + + const errorContext = getErrorContextForCopilot(task, args.message) || args.message; + + const scopeLabel = typeof args.workspaceFolder === 'object' + ? args.workspaceFolder.name + : localize('funcHostDebug.globalScope', 'Global'); + + const prompt = [ + 'I am debugging an Azure Functions project locally in VS Code.', + `Function Host Port: ${args.portNumber}`, + `Workspace: ${scopeLabel}`, + args.cwd ? `CWD: ${args.cwd}` : undefined, + '', + 'The Functions host produced an error. Diagnose the likely cause and suggest concrete next steps to fix it.', + '', + 'Error output (with surrounding context):', + errorContext, + ].filter((l): l is string => Boolean(l)).join('\n'); + + await openCopilotChat(prompt); + }); + + registerCommand('azureFunctions.funcHostDebug.refresh', async (actionContext: IActionContext) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + provider.refresh(); + await refreshFuncHostDebugContext(); + }); +} diff --git a/src/extension.ts b/src/extension.ts index 816e1f7c8..3bd72c693 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,11 +22,11 @@ import { registerCommands } from './commands/registerCommands'; import { func } from './constants'; import { BallerinaDebugProvider } from './debug/BallerinaDebugProvider'; import { FuncTaskProvider } from './debug/FuncTaskProvider'; -import { registerFunctionHostDebugView } from './debug/FunctionHostDebugView'; import { JavaDebugProvider } from './debug/JavaDebugProvider'; import { NodeDebugProvider } from './debug/NodeDebugProvider'; import { PowerShellDebugProvider } from './debug/PowerShellDebugProvider'; import { PythonDebugProvider } from './debug/PythonDebugProvider'; +import { registerFunctionHostDebugView } from './debug/registerFunctionHostDebugView'; import { handleUri } from './downloadAzureProject/handleUri'; import { ext } from './extensionVariables'; import { registerFuncHostTaskEvents, terminalEventReader } from './funcCoreTools/funcHostTask'; @@ -94,7 +94,6 @@ export async function activateInternal(context: vscode.ExtensionContext, perfSta }); registerFuncHostTaskEvents(); - registerFunctionHostDebugView(context); const nodeDebugProvider: NodeDebugProvider = new NodeDebugProvider(); diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index bfe155626..c17b830a8 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -275,7 +275,7 @@ export function registerFuncHostTaskEvents(): void { if (getWorkspaceSetting('stopFuncTaskPostDebug') && !debugSession.parentSession && debugSession.workspaceFolder) { // TODO: Find the exact function task from the debug session, but for now just stop all tasks in the workspace folder await stopFuncTaskIfRunning(debugSession.workspaceFolder, undefined, true, false); - + terminalEventReader.dispose(); } }); } diff --git a/src/tree/localProject/LocalProjectTreeItem.ts b/src/tree/localProject/LocalProjectTreeItem.ts index d9dcaeb0b..e4c75b773 100644 --- a/src/tree/localProject/LocalProjectTreeItem.ts +++ b/src/tree/localProject/LocalProjectTreeItem.ts @@ -59,7 +59,6 @@ export class LocalProjectTreeItem extends LocalProjectTreeItemBase implements Di this._disposables.push(createRefreshFileWatcher(this, path.join(this.effectiveProjectPath, localSettingsFileName))); this._disposables.push(onFuncTaskStarted(async event => this.onFuncTaskChanged(event))); - // this._disposables.push(onFuncTaskStarted(async scope => this.onFuncTaskChanged(scope))); this._disposables.push(onDotnetFuncTaskReady(async scope => this.onFuncTaskChanged({ scope }))); this._localFunctionsTreeItem = new LocalFunctionsTreeItem(this); diff --git a/test/funcHostErrorContext.test.ts b/test/funcHostErrorContext.test.ts index 67588a450..b046c41a0 100644 --- a/test/funcHostErrorContext.test.ts +++ b/test/funcHostErrorContext.test.ts @@ -14,7 +14,7 @@ suite('Function host error context extraction', () => { assert.strictEqual(isFuncHostErrorLog('normal log line'), false); }); - test('extractFuncHostErrorContext includes surrounding window', () => { + test('extractFuncHostErrorContext extracts only red output', () => { const logs = [ 'line 0\n', 'line 1\n', @@ -25,13 +25,11 @@ suite('Function host error context extraction', () => { const extracted = extractFuncHostErrorContext(logs, { before: 1, after: 1, max: 250 }); assert.deepStrictEqual(extracted, [ - 'line 1\n', - '\u001b[31m[Error] Something failed\u001b[39m\n', - 'line 3\n', + '[Error] Something failed\n', ]); }); - test('extractFuncHostErrorContextForErrorMessage only includes the selected error window', () => { + test('extractFuncHostErrorContextForErrorMessage returns only the matching red entry', () => { const logs = [ 'line 0\n', '\u001b[31m[Error] First\u001b[39m\n', @@ -42,9 +40,7 @@ suite('Function host error context extraction', () => { const extracted = extractFuncHostErrorContextForErrorMessage(logs, '[Error] Second', { before: 1, after: 1, max: 250 }); assert.deepStrictEqual(extracted, [ - 'line 2\n', - '\u001b[31m[Error] Second\u001b[39m\n', - 'line 4\n', + '[Error] Second\n', ]); }); }); diff --git a/test/funcHostErrorGrouping.test.ts b/test/funcHostErrorGrouping.test.ts index e69de29bb..8d672bafa 100644 --- a/test/funcHostErrorGrouping.test.ts +++ b/test/funcHostErrorGrouping.test.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; + +// eslint-disable-next-line no-restricted-imports +import { extractFuncHostErrorContext } from '../src/funcCoreTools/funcHostErrorUtils'; + +suite('Function host error grouping', () => { + test('groups consecutive red entries into a single entry', () => { + const logs = [ + '\u001b[31m[Error] First\u001b[39m\n', + '\u001b[31m[Error] Second\u001b[39m\n', + ]; + + const extracted = extractFuncHostErrorContext(logs); + assert.deepStrictEqual(extracted, ['[Error] First\n[Error] Second\n']); + }); + + test('does not group red entries separated by non-red output', () => { + const logs = [ + '\u001b[31m[Error] First\u001b[39m\n', + 'normal\n', + '\u001b[31m[Error] Second\u001b[39m\n', + ]; + + const extracted = extractFuncHostErrorContext(logs); + assert.deepStrictEqual(extracted, ['[Error] First\n', '[Error] Second\n']); + }); +}); + From 8fae44681507980e7f1267c0c2fc30e1be8a9955 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 14 Jan 2026 14:02:27 -0800 Subject: [PATCH 30/38] Remove the stop action and implement clear errors --- package.json | 15 ---------- package.nls.json | 1 - src/debug/registerFunctionHostDebugView.ts | 33 +++++++++++----------- src/funcCoreTools/funcHostTask.ts | 27 ++++++++++++++---- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index cc32d8c54..f6fa9a531 100644 --- a/package.json +++ b/package.json @@ -502,12 +502,6 @@ "category": "Azure Functions", "icon": "$(copy)" }, - { - "command": "azureFunctions.funcHostDebug.stop", - "title": "%azureFunctions.funcHostDebug.stop%", - "category": "Azure Functions", - "icon": "$(debug-stop)" - }, { "command": "azureFunctions.funcHostDebug.askCopilot", "title": "%azureFunctions.funcHostDebug.askCopilot%", @@ -572,11 +566,6 @@ "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostTask", "group": "inline" }, - { - "command": "azureFunctions.funcHostDebug.stop", - "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostTask", - "group": "inline" - }, { "command": "azureFunctions.funcHostDebug.askCopilot", "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostError", @@ -1036,10 +1025,6 @@ "command": "azureFunctions.funcHostDebug.copyRecentLogs", "when": "never" }, - { - "command": "azureFunctions.funcHostDebug.stop", - "when": "never" - }, { "command": "azureFunctions.funcHostDebug.askCopilot", "when": "never" diff --git a/package.nls.json b/package.nls.json index a161a8fab..ee7d77fc5 100644 --- a/package.nls.json +++ b/package.nls.json @@ -151,6 +151,5 @@ "azureFunctions.funcHostDebug.clearErrors": "Clear Function Host Errors", "azureFunctions.funcHostDebug.showRecentLogs": "Show Recent Host Logs", "azureFunctions.funcHostDebug.copyRecentLogs": "Copy Recent Host Logs", - "azureFunctions.funcHostDebug.stop": "Stop Function Host", "azureFunctions.funcHostDebug.askCopilot": "Ask Copilot" } diff --git a/src/debug/registerFunctionHostDebugView.ts b/src/debug/registerFunctionHostDebugView.ts index 4ab20d1e2..e41b0525e 100644 --- a/src/debug/registerFunctionHostDebugView.ts +++ b/src/debug/registerFunctionHostDebugView.ts @@ -5,8 +5,7 @@ import { registerCommand, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; -import { extractFuncHostErrorContextForErrorMessage } from '../funcCoreTools/funcHostErrorUtils'; -import { onRunningFuncTasksChanged, refreshFuncHostDebugContext, runningFuncTaskMap, stopFuncTaskIfRunning, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; +import { onRunningFuncTasksChanged, refreshFuncHostDebugContext, runningFuncTaskMap, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; import { localize } from '../localize'; import { stripAnsiControlCharacters } from '../utils/ansiUtils'; import { openCopilotChat } from '../utils/copilotChat'; @@ -85,13 +84,6 @@ function getRecentLogsPlainText(task: IRunningFuncTask | undefined, limit: numbe return stripAnsiControlCharacters(getRecentLogs(task, limit)); } -function getErrorContextForCopilot(task: IRunningFuncTask | undefined, errorMessage: string): string { - const logs = task?.logs ?? []; - const extracted = extractFuncHostErrorContextForErrorMessage(logs, errorMessage, { before: 5, after: 25, max: 250 }); - const plainLines = extracted.map((l) => stripAnsiControlCharacters(l)); - return plainLines.join('').trim(); -} - export function registerFunctionHostDebugView(context: vscode.ExtensionContext): void { const provider = new FuncHostDebugViewProvider(); @@ -106,13 +98,21 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): // Ensure the context key is correct on activation. void refreshFuncHostDebugContext(); - registerCommand('azureFunctions.funcHostDebug.stop', async (actionContext: IActionContext, args: unknown) => { + registerCommand('azureFunctions.funcHostDebug.clearErrors', async (actionContext: IActionContext) => { actionContext.telemetry.properties.source = 'funcHostDebugView'; - if (!isHostTaskNode(args)) { - return; + for (const folder of vscode.workspace.workspaceFolders ?? []) { + for (const t of runningFuncTaskMap.getAll(folder)) { + if (!t) { + continue; + } + + if ((t.errorLogs?.length ?? 0) > 0) { + t.errorLogs = []; + } + } } - await stopFuncTaskIfRunning(args.workspaceFolder, args.cwd, false, false); + provider.refresh(); }); registerCommand('azureFunctions.funcHostDebug.copyRecentLogs', async (actionContext: IActionContext, args: unknown) => { @@ -149,9 +149,8 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): return; } - const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); - - const errorContext = getErrorContextForCopilot(task, args.message) || args.message; + // Use the exact message shown in the error node tooltip. + const errorContext = stripAnsiControlCharacters(args.message).trim() || args.message; const scopeLabel = typeof args.workspaceFolder === 'object' ? args.workspaceFolder.name @@ -165,7 +164,7 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): '', 'The Functions host produced an error. Diagnose the likely cause and suggest concrete next steps to fix it.', '', - 'Error output (with surrounding context):', + 'Error output:', errorContext, ].filter((l): l is string => Boolean(l)).join('\n'); diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index c17b830a8..d2dd45e0b 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -81,11 +81,28 @@ class RunningFunctionTaskMap { public get(key: vscode.WorkspaceFolder | vscode.TaskScope, buildPath?: string): IRunningFuncTask | undefined { const values = this._map.get(key) || []; return values.find(t => { - const taskExecution = t.taskExecution.task.execution as vscode.ShellExecution; - // the cwd will include ${workspaceFolder} from our tasks.json so we need to replace it with the actual path - const taskDirectory = taskExecution.options?.cwd?.replace('${workspaceFolder}', (t.taskExecution.task?.scope as vscode.WorkspaceFolder).uri?.path) - buildPath = buildPath?.replace('${workspaceFolder}', (t.taskExecution.task?.scope as vscode.WorkspaceFolder).uri?.path) - return taskDirectory && buildPath && normalizePath(taskDirectory) === normalizePath(buildPath); + const taskExecution = t.taskExecution.task.execution; + if (!(taskExecution instanceof vscode.ShellExecution)) { + return false; + } + + const scope = t.taskExecution.task?.scope; + const workspaceFolderPath = typeof scope === 'object' ? scope.uri?.path : undefined; + if (!workspaceFolderPath) { + return false; + } + + // The cwd/buildPath will include ${workspaceFolder} from our tasks.json so we need to replace it with the actual path + const cwd = taskExecution.options?.cwd; + const taskDirectory = typeof cwd === 'string' + ? cwd.replace('${workspaceFolder}', workspaceFolderPath) + : undefined; + + const resolvedBuildPath = typeof buildPath === 'string' + ? buildPath.replace('${workspaceFolder}', workspaceFolderPath) + : undefined; + + return taskDirectory && resolvedBuildPath && normalizePath(taskDirectory) === normalizePath(resolvedBuildPath); }); } From 9e897af82e8c20cb74fa046d51af4e4ea30197db Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 14 Jan 2026 14:45:03 -0800 Subject: [PATCH 31/38] Clean up some code --- extension.bundle.ts | 1 + src/debug/FunctionHostDebugView.ts | 8 ----- src/debug/registerFunctionHostDebugView.ts | 1 - src/funcCoreTools/funcHostErrorUtils.ts | 36 ---------------------- src/funcCoreTools/funcHostTask.ts | 33 +++----------------- src/utils/ansiUtils.ts | 1 - src/utils/copilotChat.ts | 1 - test/funcHostErrorContext.test.ts | 17 +--------- test/funcHostErrorGrouping.test.ts | 33 -------------------- 9 files changed, 7 insertions(+), 124 deletions(-) delete mode 100644 test/funcHostErrorGrouping.test.ts diff --git a/extension.bundle.ts b/extension.bundle.ts index 07c2300a0..3738b446f 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -33,6 +33,7 @@ export * from './src/utils/durableUtils'; export { activateInternal, deactivateInternal } from './src/extension'; export * from './src/extensionVariables'; export * from './src/funcConfig/function'; +export { extractFuncHostErrorContextForErrorMessage, isFuncHostErrorLog } from './src/funcCoreTools/funcHostErrorUtils'; export * from './src/funcCoreTools/hasMinFuncCliVersion'; export * from './src/FuncVersion'; export * from './src/templates/CentralTemplateProvider'; diff --git a/src/debug/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts index 74a96497b..b70144b08 100644 --- a/src/debug/FunctionHostDebugView.ts +++ b/src/debug/FunctionHostDebugView.ts @@ -129,14 +129,6 @@ export class FuncHostDebugViewProvider implements vscode.TreeDataProvider { diff --git a/src/funcCoreTools/funcHostErrorUtils.ts b/src/funcCoreTools/funcHostErrorUtils.ts index 639acdf00..a9b5792b4 100644 --- a/src/funcCoreTools/funcHostErrorUtils.ts +++ b/src/funcCoreTools/funcHostErrorUtils.ts @@ -27,42 +27,6 @@ export function isFuncHostErrorLog(log: string): boolean { return redAnsiRegex.test(log); } -/** - * Extracts likely error output from the function host log stream, including a small window - * of surrounding context to help diagnose issues (e.g., stack traces that may not be red). - */ -export function extractFuncHostErrorContext(logs: readonly string[], options?: FuncHostErrorContextOptions): string[] { - const before = options?.before ?? 5; - const after = options?.after ?? 15; - const max = options?.max ?? 250; - - const includeIndices = new Set(); - for (let i = 0; i < logs.length; i++) { - if (isFuncHostErrorLog(logs[i])) { - const start = Math.max(0, i - before); - const end = Math.min(logs.length - 1, i + after); - for (let j = start; j <= end; j++) { - includeIndices.add(j); - } - } - } - - // Preserve order - const result: string[] = []; - for (let i = 0; i < logs.length; i++) { - if (includeIndices.has(i)) { - result.push(logs[i]); - } - } - - // Keep most recent `max` lines - if (result.length > max) { - return result.slice(result.length - max); - } - - return result; -} - /** * Extracts context for only a single relevant error line (as selected in the UI). * diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index d2dd45e0b..ffa54d582 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -81,28 +81,11 @@ class RunningFunctionTaskMap { public get(key: vscode.WorkspaceFolder | vscode.TaskScope, buildPath?: string): IRunningFuncTask | undefined { const values = this._map.get(key) || []; return values.find(t => { - const taskExecution = t.taskExecution.task.execution; - if (!(taskExecution instanceof vscode.ShellExecution)) { - return false; - } - - const scope = t.taskExecution.task?.scope; - const workspaceFolderPath = typeof scope === 'object' ? scope.uri?.path : undefined; - if (!workspaceFolderPath) { - return false; - } - - // The cwd/buildPath will include ${workspaceFolder} from our tasks.json so we need to replace it with the actual path - const cwd = taskExecution.options?.cwd; - const taskDirectory = typeof cwd === 'string' - ? cwd.replace('${workspaceFolder}', workspaceFolderPath) - : undefined; - - const resolvedBuildPath = typeof buildPath === 'string' - ? buildPath.replace('${workspaceFolder}', workspaceFolderPath) - : undefined; - - return taskDirectory && resolvedBuildPath && normalizePath(taskDirectory) === normalizePath(resolvedBuildPath); + const taskExecution = t.taskExecution.task.execution as vscode.ShellExecution; + // the cwd will include ${workspaceFolder} from our tasks.json so we need to replace it with the actual path + const taskDirectory = taskExecution.options?.cwd?.replace('${workspaceFolder}', (t.taskExecution.task?.scope as vscode.WorkspaceFolder).uri?.path) + buildPath = buildPath?.replace('${workspaceFolder}', (t.taskExecution.task?.scope as vscode.WorkspaceFolder).uri?.path) + return taskDirectory && buildPath && normalizePath(taskDirectory) === normalizePath(buildPath); }); } @@ -151,12 +134,6 @@ function getAllRunningFuncTasks(): IRunningFuncTaskWithScope[] { } } - // Also include tasks started with global scope - for (const t of runningFuncTaskMap.getAll(vscode.TaskScope.Global)) { - if (t) { - tasks.push({ scope: vscode.TaskScope.Global, task: t }); - } - } return tasks; } diff --git a/src/utils/ansiUtils.ts b/src/utils/ansiUtils.ts index ba330a285..5a0d9d254 100644 --- a/src/utils/ansiUtils.ts +++ b/src/utils/ansiUtils.ts @@ -5,7 +5,6 @@ /** * Removes ANSI escape sequences and other terminal control characters from a string. - * * This is intended for presenting Function Host output in a plain text editor / clipboard. */ export function stripAnsiControlCharacters(text: string): string { diff --git a/src/utils/copilotChat.ts b/src/utils/copilotChat.ts index 90522722f..532f5c4fc 100644 --- a/src/utils/copilotChat.ts +++ b/src/utils/copilotChat.ts @@ -9,7 +9,6 @@ import { localize } from '../localize'; /** * Best-effort helper to open GitHub Copilot Chat with a pre-filled prompt. - * * VS Code command IDs and argument shapes have evolved over time, so we try a few. */ export async function openCopilotChat(prompt: string): Promise { diff --git a/test/funcHostErrorContext.test.ts b/test/funcHostErrorContext.test.ts index b046c41a0..b56fb9210 100644 --- a/test/funcHostErrorContext.test.ts +++ b/test/funcHostErrorContext.test.ts @@ -6,7 +6,7 @@ import * as assert from 'assert'; // eslint-disable-next-line no-restricted-imports -import { extractFuncHostErrorContext, extractFuncHostErrorContextForErrorMessage, isFuncHostErrorLog } from '../src/funcCoreTools/funcHostErrorUtils'; +import { extractFuncHostErrorContextForErrorMessage, isFuncHostErrorLog } from '../extension.bundle'; suite('Function host error context extraction', () => { test('detects red ANSI as error', () => { @@ -14,21 +14,6 @@ suite('Function host error context extraction', () => { assert.strictEqual(isFuncHostErrorLog('normal log line'), false); }); - test('extractFuncHostErrorContext extracts only red output', () => { - const logs = [ - 'line 0\n', - 'line 1\n', - '\u001b[31m[Error] Something failed\u001b[39m\n', - 'line 3\n', - 'line 4\n', - ]; - - const extracted = extractFuncHostErrorContext(logs, { before: 1, after: 1, max: 250 }); - assert.deepStrictEqual(extracted, [ - '[Error] Something failed\n', - ]); - }); - test('extractFuncHostErrorContextForErrorMessage returns only the matching red entry', () => { const logs = [ 'line 0\n', diff --git a/test/funcHostErrorGrouping.test.ts b/test/funcHostErrorGrouping.test.ts deleted file mode 100644 index 8d672bafa..000000000 --- a/test/funcHostErrorGrouping.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE.md in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as assert from 'assert'; - -// eslint-disable-next-line no-restricted-imports -import { extractFuncHostErrorContext } from '../src/funcCoreTools/funcHostErrorUtils'; - -suite('Function host error grouping', () => { - test('groups consecutive red entries into a single entry', () => { - const logs = [ - '\u001b[31m[Error] First\u001b[39m\n', - '\u001b[31m[Error] Second\u001b[39m\n', - ]; - - const extracted = extractFuncHostErrorContext(logs); - assert.deepStrictEqual(extracted, ['[Error] First\n[Error] Second\n']); - }); - - test('does not group red entries separated by non-red output', () => { - const logs = [ - '\u001b[31m[Error] First\u001b[39m\n', - 'normal\n', - '\u001b[31m[Error] Second\u001b[39m\n', - ]; - - const extracted = extractFuncHostErrorContext(logs); - assert.deepStrictEqual(extracted, ['[Error] First\n', '[Error] Second\n']); - }); -}); - From d30fda17aee4e7dc823791fabc9d01d3d1d9b733 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 14 Jan 2026 15:42:46 -0800 Subject: [PATCH 32/38] Remove the terminal dispose since it is not reinstantiated later --- src/funcCoreTools/funcHostTask.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index ffa54d582..9d2e8a7a4 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -269,7 +269,6 @@ export function registerFuncHostTaskEvents(): void { if (getWorkspaceSetting('stopFuncTaskPostDebug') && !debugSession.parentSession && debugSession.workspaceFolder) { // TODO: Find the exact function task from the debug session, but for now just stop all tasks in the workspace folder await stopFuncTaskIfRunning(debugSession.workspaceFolder, undefined, true, false); - terminalEventReader.dispose(); } }); } From 759fb6ad5c1f30a3c91a69541dd5daa17d23cb87 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 14 Jan 2026 16:03:46 -0800 Subject: [PATCH 33/38] Update src/funcCoreTools/funcHostTask.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/funcCoreTools/funcHostTask.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 9d2e8a7a4..e741ce006 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -232,8 +232,13 @@ export function registerFuncHostTaskEvents(): void { return; } + const maxLogEntries = 1000; + for await (const chunk of task.stream ?? []) { task.logs.push(chunk); + if (task.logs.length > maxLogEntries) { + task.logs.splice(0, task.logs.length - maxLogEntries); + } // Keep track of errors for the Debug view. if (isFuncHostErrorLog(chunk)) { From 8ac06645ca9a24af624b33de0a3e77a9ed13e759 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 14 Jan 2026 16:04:47 -0800 Subject: [PATCH 34/38] Update src/funcCoreTools/funcHostErrorUtils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/funcCoreTools/funcHostErrorUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/funcCoreTools/funcHostErrorUtils.ts b/src/funcCoreTools/funcHostErrorUtils.ts index a9b5792b4..8d343f150 100644 --- a/src/funcCoreTools/funcHostErrorUtils.ts +++ b/src/funcCoreTools/funcHostErrorUtils.ts @@ -21,7 +21,7 @@ export interface FuncHostErrorContextOptions { } // eslint-disable-next-line no-control-regex -const redAnsiRegex = /\u001b\[(?:[0-9;]*31m|[0-9;]*91m|38;5;(9|1)m)/; +const redAnsiRegex = /\u001b\[(?:[0-9;]*31m|[0-9;]*91m)/; export function isFuncHostErrorLog(log: string): boolean { return redAnsiRegex.test(log); From 421322280c2457bd179f18bf39eff98cf8cc3748 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:15:23 -0800 Subject: [PATCH 35/38] Add JSDoc documentation for IStartFuncProcessResult stream property (#4885) * Initial plan * Add JSDoc documentation for IStartFuncProcessResult interface Co-authored-by: nturinski <5290572+nturinski@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nturinski <5290572+nturinski@users.noreply.github.com> --- package-lock.json | 23 ++------------------ src/commands/pickFuncProcess.ts | 37 ++++++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70322c9e8..9a5abc202 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1199,7 +1199,6 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-azureappsettings/-/vscode-azext-azureappsettings-0.2.11.tgz", "integrity": "sha512-zu6RedxoVEgOTyTqyeo5HHPowK3aEYyD9if96b3TF3fkiuxuRlR/19V0qmoKxF6w8nYYhuUDScL2i+zlcu9+xQ==", - "peer": true, "dependencies": { "@microsoft/vscode-azext-utils": "^3.4.2" } @@ -1544,7 +1543,6 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -1834,8 +1832,7 @@ "version": "16.18.126", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", - "devOptional": true, - "peer": true + "devOptional": true }, "node_modules/@types/picomatch": { "version": "3.0.2", @@ -1951,7 +1948,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -1986,7 +1982,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -2570,7 +2565,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2625,7 +2619,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3161,7 +3154,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -3225,7 +3217,6 @@ "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", "hasInstallScript": true, - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -4530,7 +4521,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4632,7 +4622,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -7640,7 +7629,6 @@ "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", "dev": true, - "peer": true, "dependencies": { "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", @@ -10084,7 +10072,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10393,8 +10380,7 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "peer": true + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -10582,7 +10568,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10788,7 +10773,6 @@ "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", "hasInstallScript": true, - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -11050,7 +11034,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", "dev": true, - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -11097,7 +11080,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -11199,7 +11181,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 058b61673..e403c6d96 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -21,17 +21,40 @@ import { getWorkspaceSetting } from '../vsCodeConfig/settings'; const funcTaskReadyEmitter = new vscode.EventEmitter(); export const onDotnetFuncTaskReady = funcTaskReadyEmitter.event; +/** + * Result returned from starting a function host process via the API. + */ +export interface IStartFuncProcessResult { + /** + * The process ID of the started function host. + */ + processId: string; + /** + * Whether the function host was successfully started. + */ + success: boolean; + /** + * Error message if the function host failed to start. + */ + error: string; + /** + * An async iterable stream of terminal output from the function host task. + * This stream provides real-time access to the output of the `func host start` command, + * allowing consumers to monitor host status, capture logs, and detect errors. + * + * The stream will be undefined if the host failed to start or if output streaming is not available. + * Consumers should iterate over the stream asynchronously to read output lines as they are produced. + * The stream remains active for the lifetime of the function host process. + */ + stream: AsyncIterable | undefined; +} + export async function startFuncProcessFromApi( buildPath: string, args: string[], env: { [key: string]: string } -): Promise<{ processId: string; success: boolean; error: string, stream: AsyncIterable | undefined }> { - const result: { - processId: string; - success: boolean; - error: string; - stream: AsyncIterable | undefined; - } = { +): Promise { + const result: IStartFuncProcessResult = { processId: '', success: false, error: '', From db407f4cd1a571dc302e45c1aedf77b45bd040ac Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:17:31 -0800 Subject: [PATCH 36/38] Prevent infinite stream iteration when func host task terminates (#4887) * Initial plan * Add AbortController to prevent infinite stream iteration Co-authored-by: nturinski <5290572+nturinski@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nturinski <5290572+nturinski@users.noreply.github.com> --- src/funcCoreTools/funcHostTask.ts | 53 ++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index e741ce006..dc55c346d 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -32,6 +32,11 @@ export interface IRunningFuncTask { * This avoids repeatedly stealing focus / opening the view for every subsequent error. */ hasReportedLiveErrors?: boolean; + /** + * AbortController used to signal when the stream iteration should stop. + * This prevents the async iteration loop from hanging indefinitely when the task ends. + */ + streamAbortController?: AbortController; } function addErrorLog(task: IRunningFuncTask, rawChunk: string): void { @@ -188,6 +193,7 @@ export function registerFuncHostTaskEvents(): void { logs, errorLogs: [], hasReportedLiveErrors: false, + streamAbortController: new AbortController(), }; runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask); @@ -211,6 +217,13 @@ export function registerFuncHostTaskEvents(): void { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { + const task = runningFuncTaskMap.get(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); + + // Abort the stream iteration to prevent it from hanging indefinitely + if (task?.streamAbortController) { + task.streamAbortController.abort(); + } + runningFuncTaskMap.delete(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); runningFuncTasksChangedEmitter.fire(); @@ -234,21 +247,37 @@ export function registerFuncHostTaskEvents(): void { const maxLogEntries = 1000; - for await (const chunk of task.stream ?? []) { - task.logs.push(chunk); - if (task.logs.length > maxLogEntries) { - task.logs.splice(0, task.logs.length - maxLogEntries); - } + try { + for await (const chunk of task.stream ?? []) { + // Check if the stream iteration should be aborted + if (task.streamAbortController?.signal.aborted) { + break; + } - // Keep track of errors for the Debug view. - if (isFuncHostErrorLog(chunk)) { - const beforeCount = task.errorLogs?.length ?? 0; - addErrorLog(task, chunk); - const afterCount = task.errorLogs?.length ?? 0; - if (afterCount > beforeCount) { - runningFuncTasksChangedEmitter.fire(); + task.logs.push(chunk); + if (task.logs.length > maxLogEntries) { + task.logs.splice(0, task.logs.length - maxLogEntries); } + + // Keep track of errors for the Debug view. + if (isFuncHostErrorLog(chunk)) { + const beforeCount = task.errorLogs?.length ?? 0; + addErrorLog(task, chunk); + const afterCount = task.errorLogs?.length ?? 0; + if (afterCount > beforeCount) { + runningFuncTasksChangedEmitter.fire(); + } + } + } + } catch (error) { + // If the stream encounters an error or is aborted, gracefully exit the loop + // This prevents the event handler from hanging indefinitely + if (task.streamAbortController?.signal.aborted) { + // Expected when the task ends - no need to log + return; } + // Log unexpected errors but don't throw to avoid crashing the extension + console.error('Error reading func host task stream:', error); } }); From e8fea6583dd82324fff334e5ea937e016c2dff05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:21:48 +0000 Subject: [PATCH 37/38] Initial plan From d076846e91c08f041e82749db561a29f9633991f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:32:06 +0000 Subject: [PATCH 38/38] Fix race condition in terminal event handling by filtering func host events Co-authored-by: nturinski <5290572+nturinski@users.noreply.github.com> --- src/funcCoreTools/funcHostTask.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index dc55c346d..107b8d5fa 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -172,10 +172,12 @@ 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, 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 + * Only store terminal events for func host start commands to avoid race conditions + * where a non-func terminal opened just before the func task starts could be incorrectly captured. * */ - latestTerminalShellExecutionEvent = terminalShellExecEvent; + if (isFuncShellEvent(terminalShellExecEvent)) { + latestTerminalShellExecutionEvent = terminalShellExecEvent; + } }); registerEvent('azureFunctions.onDidStartTask', vscode.tasks.onDidStartTaskProcess, async (context: IActionContext, e: vscode.TaskProcessStartEvent) => { context.errorHandling.suppressDisplay = true;