From 6e5b61cc5a000f275b923252e10fbaec553cd248 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Fri, 24 Oct 2025 10:55:11 -0700 Subject: [PATCH 01/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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/51] 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 24d396e1d761cf6fac406262d085227ddce3033e Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 14 Jan 2026 16:35:16 -0800 Subject: [PATCH 37/51] Update src/debug/FunctionHostDebugView.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/debug/FunctionHostDebugView.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/debug/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts index b70144b08..1d88852cc 100644 --- a/src/debug/FunctionHostDebugView.ts +++ b/src/debug/FunctionHostDebugView.ts @@ -92,9 +92,11 @@ export class FuncHostDebugViewProvider implements vscode.TreeDataProvider Date: Thu, 15 Jan 2026 10:15:42 -0800 Subject: [PATCH 38/51] Remove exhaustive check from switch statement Removed exhaustive check for FuncHostDebugNode. --- src/debug/FunctionHostDebugView.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/debug/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts index 1d88852cc..67ae0fb07 100644 --- a/src/debug/FunctionHostDebugView.ts +++ b/src/debug/FunctionHostDebugView.ts @@ -94,7 +94,6 @@ export class FuncHostDebugViewProvider implements vscode.TreeDataProvider Date: Wed, 25 Feb 2026 14:59:15 -0800 Subject: [PATCH 39/51] npm install to update package-lock --- package-lock.json | 336 +++------------------------------------------- 1 file changed, 21 insertions(+), 315 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67d8641af..6e211d72d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1809,9 +1809,10 @@ } }, "node_modules/@microsoft/vscode-azext-azureappsettings": { - "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==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/vscode-azext-azureappsettings/-/vscode-azext-azureappsettings-1.0.0.tgz", + "integrity": "sha512-uvTOZtgONxkKD88t6aOAlzTVKDQqGbAzNePsozhiffX4EAxLs6HXX98FUlKI+AHctQFlrny+y2UMmYnH87nNsA==", + "license": "MIT", "peer": true, "dependencies": { "@microsoft/vscode-azext-utils": "^4.0.0" @@ -3047,11 +3048,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "16.18.126", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", - "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "devOptional": true, - "peer": true + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", @@ -3128,7 +3132,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", @@ -3412,6 +3415,7 @@ "integrity": "sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/mocha": "^10.0.10", "c8": "^10.1.3", @@ -3640,6 +3644,7 @@ "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.5", @@ -3657,6 +3662,7 @@ "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@azure/identity": "^4.1.0", "@secretlint/node": "^10.1.2", @@ -4708,6 +4714,7 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5601,6 +5608,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5642,6 +5650,7 @@ "integrity": "sha512-Bk66jpevTcV8KMFzZI1P7MZKZ+uDcrZm2G2egZ2jNIvVnivDpodZI+/KnpL3Jnap0PBdIHU7HwFGB8r+vV5CVw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.1.2", "chokidar": "^3.5.3", @@ -5758,91 +5767,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", - "dev": true, - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.31.0", - "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", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", - "hasown": "^2.0.2", - "is-core-module": "^2.15.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.0", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, "node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -9977,69 +9901,6 @@ } } }, - "node_modules/terser-webpack-plugin/node_modules/ajv": { - "version": "8.17.1", - "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", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/terser-webpack-plugin/node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -10189,6 +10050,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10681,166 +10543,10 @@ } } }, - "node_modules/webpack-cli": { - "version": "4.10.0", - "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", - "@webpack-cli/info": "^1.5.0", - "@webpack-cli/serve": "^1.7.0", - "colorette": "^2.0.14", - "commander": "^7.0.0", - "cross-spawn": "^7.0.3", - "fastest-levenshtein": "^1.0.12", - "import-local": "^3.0.2", - "interpret": "^2.2.0", - "rechoir": "^0.7.0", - "webpack-merge": "^5.7.3" - }, - "bin": { - "webpack-cli": "bin/cli.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "4.x.x || 5.x.x" - }, - "peerDependenciesMeta": { - "@webpack-cli/generators": { - "optional": true - }, - "@webpack-cli/migrate": { - "optional": true - }, - "webpack-bundle-analyzer": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - } - } - }, - "node_modules/webpack-cli/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/webpack-cli/node_modules/interpret": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", - "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/webpack-cli/node_modules/rechoir": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", - "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", - "dev": true, - "dependencies": { - "resolve": "^1.9.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/webpack-merge": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", - "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", - "dev": true, - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/webpack-sources": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", - "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", - "dev": true, - "dependencies": { - "source-list-map": "^2.0.0", - "source-map": "~0.6.1" - } - }, - "node_modules/webpack/node_modules/ajv": { - "version": "8.17.1", - "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", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/webpack/node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "dev": true, "license": "MIT", "engines": { From 26a9e6bb9d75d84dbc2b66bd69a3ecbad2b0474c Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 25 Feb 2026 15:16:51 -0800 Subject: [PATCH 40/51] Convert shell args to be an array --- src/debug/FuncTaskProvider.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/debug/FuncTaskProvider.ts b/src/debug/FuncTaskProvider.ts index 1b89c1b4c..0174570c8 100644 --- a/src/debug/FuncTaskProvider.ts +++ b/src/debug/FuncTaskProvider.ts @@ -104,15 +104,11 @@ export class FuncTaskProvider implements TaskProvider { private async createTask(context: IActionContext, command: string, folder: WorkspaceFolder, projectRoot: string | undefined, language: string | undefined, definition?: TaskDefinition): Promise { const funcCliPath = await getFuncCliPath(context, folder); - const args = (definition?.args || []) as string[]; - if (args.length > 0) { - command = `${command} ${args.join(' ')}`; - } + const definitionArgs = (definition?.args || []) as string[]; - let commandLine: string = `${funcCliPath} ${command}`; - if (language === ProjectLanguage.Python) { - commandLine = venvUtils.convertToVenvCommand(commandLine, folder.uri.fsPath); - } + // Split command into sub-command parts (e.g., "host start" → ["host", "start"]) + const commandParts = command.split(/\s+/); + const allArgs = [...commandParts, ...definitionArgs]; let problemMatcher: string | undefined; let options: ShellExecutionOptions | undefined; @@ -137,8 +133,20 @@ export class FuncTaskProvider implements TaskProvider { }; } + let execution: ShellExecution; + if (language === ProjectLanguage.Python) { + // Python requires chaining venv activation with the func command via shell operators (&&, ;), + // so we must use the string-based ShellExecution form + let commandLine = `${funcCliPath} ${[...commandParts, ...definitionArgs].join(' ')}`; + commandLine = venvUtils.convertToVenvCommand(commandLine, folder.uri.fsPath); + execution = new ShellExecution(commandLine, options); + } else { + // Use the arg-list form so VS Code handles proper quoting/escaping + execution = new ShellExecution(funcCliPath, allArgs, options); + } + definition = definition || { type: func, command }; - return new Task(definition, folder, command, func, new ShellExecution(commandLine, options), problemMatcher); + return new Task(definition, folder, command, func, execution, problemMatcher); } private async getHostStartOptions(folder: WorkspaceFolder, language: string | undefined): Promise { From a331ddc0686108dc3bbb3a6cb1e844085334ac38 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 11 Mar 2026 13:57:55 -0700 Subject: [PATCH 41/51] Fix duplicate log entries, fix func start tasks with args not being recognized --- src/funcCoreTools/funcHostErrorUtils.ts | 73 ++++++++++++++++++++++++- src/funcCoreTools/funcHostTask.ts | 73 ++++++++++++------------- 2 files changed, 107 insertions(+), 39 deletions(-) diff --git a/src/funcCoreTools/funcHostErrorUtils.ts b/src/funcCoreTools/funcHostErrorUtils.ts index 8d343f150..5e5e3950a 100644 --- a/src/funcCoreTools/funcHostErrorUtils.ts +++ b/src/funcCoreTools/funcHostErrorUtils.ts @@ -5,6 +5,22 @@ import { stripAnsiControlCharacters } from '../utils/ansiUtils'; +/** + * Regex that matches a Functions-host timestamp prefix, e.g. `[2026-03-11T19:57:44.622Z]`. + * Used to split raw terminal chunks into logical log entries. + */ +const funcHostTimestampRegex = /(?=\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z\])/; + +/** + * Collapse all whitespace runs to a single space for dedup comparison. + * Terminal reflow wraps the same logical message at different column positions + * depending on window width, producing different newline/space patterns for + * an otherwise identical log entry. + */ +function normalizeWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + export interface FuncHostErrorContextOptions { /** * Number of log lines to include before an error line @@ -20,11 +36,64 @@ export interface FuncHostErrorContextOptions { max?: number; } +// Detect red/bright-red foreground in any of the common SGR forms: +// Basic 4-bit: \x1b[31m (red) / \x1b[91m (bright red), with optional extra params +// 256-color: \x1b[38;5;1m (red) / \x1b[38;5;9m (bright red) +// 24-bit RGB: \x1b[38;2;R;G;Bm where R is dominant (R≥128, G≤64, B≤64) +// eslint-disable-next-line no-control-regex +const basicRedRegex = /\u001b\[(?:(?:\d+;)*(?:31|91)(?:;\d+)*m)/; // eslint-disable-next-line no-control-regex -const redAnsiRegex = /\u001b\[(?:[0-9;]*31m|[0-9;]*91m)/; +const extended256RedRegex = /\u001b\[38;5;(?:1|9)m/; + +function isRedAnsi(text: string): boolean { + return basicRedRegex.test(text) || extended256RedRegex.test(text); +} export function isFuncHostErrorLog(log: string): boolean { - return redAnsiRegex.test(log); + return isRedAnsi(log); +} + +/** + * Splits a raw terminal chunk into logical log entries (by timestamp boundaries), + * checks each entry for red ANSI codes, and appends only genuinely new error + * entries to the given errorLogs array. + * + * @param errorLogs The mutable array of plain-text error strings to append to. + * @param rawChunk A raw terminal output chunk (may contain ANSI + multiple log entries). + * @returns `true` if at least one new error entry was added. + */ +export function addErrorLinesFromChunk(errorLogs: string[], rawChunk: string): boolean { + const seen = new Set(errorLogs.map(normalizeWhitespace)); + let added = false; + + // Split on timestamp boundaries so each segment is a complete log entry. + for (const segment of rawChunk.split(funcHostTimestampRegex)) { + if (!isFuncHostErrorLog(segment)) { + continue; + } + + const plain = stripAnsiControlCharacters(segment).trim(); + if (!plain) { + continue; + } + + const normalized = normalizeWhitespace(plain); + if (seen.has(normalized)) { + continue; + } + + seen.add(normalized); + errorLogs.push(plain); + added = true; + } + + // Keep the most recent few to avoid unbounded memory usage. + const maxErrors = 10; + if (errorLogs.length > maxErrors) { + errorLogs.splice(0, errorLogs.length - maxErrors); + } + + return added; } /** diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index e306f90db..15d4ea2e9 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -11,10 +11,9 @@ import { tryGetFunctionProjectRoot } from '../commands/createNewProject/verifyIs import { localSettingsFileName } from '../constants'; import { getLocalSettingsJson } from '../funcConfig/local.settings'; import { localize } from '../localize'; -import { stripAnsiControlCharacters } from '../utils/ansiUtils'; import { cpUtils } from '../utils/cpUtils'; import { getWorkspaceSetting } from '../vsCodeConfig/settings'; -import { isFuncHostErrorLog } from './funcHostErrorUtils'; +import { addErrorLinesFromChunk } from './funcHostErrorUtils'; export interface IRunningFuncTask { taskExecution: vscode.TaskExecution; @@ -40,26 +39,6 @@ export interface IRunningFuncTask { streamAbortController?: AbortController; } -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; @@ -90,8 +69,14 @@ class RunningFunctionTaskMap { 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 resolvedBuildPath = buildPath?.replace('${workspaceFolder}', (t.taskExecution.task?.scope as vscode.WorkspaceFolder).uri?.path); + + // When neither cwd is set, both tasks use the default working directory — treat as a match + if (!taskDirectory && !resolvedBuildPath) { + return true; + } + + return taskDirectory && resolvedBuildPath && normalizePath(taskDirectory) === normalizePath(resolvedBuildPath); }); } @@ -157,8 +142,25 @@ 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 funcCommandRegex.test(commandLine || ''); + const execution = task.execution as vscode.ShellExecution | undefined; + if (!execution) { + return false; + } + + // String-based ShellExecution: `commandLine` contains the full command + if (execution.commandLine) { + return funcCommandRegex.test(execution.commandLine); + } + + // Args-based ShellExecution: `command` + `args` are separate + // Reconstruct the command string to test against the regex + const command = typeof execution.command === 'string' ? execution.command : execution.command?.value; + if (command && execution.args) { + const argsStr = execution.args.map(a => typeof a === 'string' ? a : a.value).join(' '); + return funcCommandRegex.test(`${command} ${argsStr}`); + } + + return false; } export function isFuncShellEvent(event: vscode.TerminalShellExecutionStartEvent): boolean { @@ -219,12 +221,12 @@ 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); - + // 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(); @@ -260,14 +262,11 @@ export function registerFuncHostTaskEvents(): void { 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(); - } + // Split chunk into log entries by timestamp, check each for red + // ANSI, and deduplicate against existing errors. + const errorArr = task.errorLogs ?? (task.errorLogs = []); + if (addErrorLinesFromChunk(errorArr, chunk)) { + runningFuncTasksChangedEmitter.fire(); } } } catch (error) { @@ -319,7 +318,7 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol if (runningFuncTask !== undefined && runningFuncTask.length > 0) { for (const runningFuncTaskItem of runningFuncTask) { - if (!runningFuncTaskItem) {break;} + if (!runningFuncTaskItem) { break; } if (terminate) { runningFuncTaskItem.taskExecution.terminate(); } else { From 383bfff78bb11c1f3b885367a3a27b6ca16fcfdc Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 11 Mar 2026 17:03:21 -0700 Subject: [PATCH 42/51] Clean up some of the code --- extension.bundle.ts | 60 ------------ src/commands/pickFuncProcess.ts | 4 +- src/debug/registerFunctionHostDebugView.ts | 41 +------- src/funcCoreTools/funcHostErrorUtils.ts | 88 +++-------------- src/utils/copilotChat.ts | 44 --------- test/funcHostErrorContext.test.ts | 109 ++++++++++++++++++--- 6 files changed, 110 insertions(+), 236 deletions(-) delete mode 100644 extension.bundle.ts delete mode 100644 src/utils/copilotChat.ts diff --git a/extension.bundle.ts b/extension.bundle.ts deleted file mode 100644 index 3738b446f..000000000 --- a/extension.bundle.ts +++ /dev/null @@ -1,60 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE.md in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * This is the external face of extension.bundle.js, the main webpack bundle for the extension. - * Anything needing to be exposed outside of the extension sources must be exported from here, because - * everything else will be in private modules in extension.bundle.js. - */ - -// Exports for tests -// The tests are not packaged with the webpack bundle and therefore only have access to code exported from this file. -// -// The tests should import '../extension.bundle'. At design-time they live in tests/ and so will pick up this file (extension.bundle.ts). -// At runtime the tests live in dist/tests and will therefore pick up the main webpack bundle at dist/extension.bundle.js. -export { createAzureClient, createGenericClient } from '@microsoft/vscode-azext-azureutils'; -export { AzExtTreeDataProvider, DialogResponses, IActionContext, nonNullValue, parseError, registerOnActionStartHandler } from '@microsoft/vscode-azext-utils'; -export * from './src/commands/addBinding/addBinding'; -export * from './src/commands/copyFunctionUrl'; -export * from './src/commands/createFunction/createFunction'; -export * from './src/commands/createFunction/dotnetSteps/DotnetNamespaceStep'; -export * from './src/commands/createFunctionApp/createFunctionApp'; -export * from './src/commands/createNewProject/createNewProject'; -export * from './src/commands/createNewProject/ProjectCreateStep/JavaScriptProjectCreateStep'; -export * from './src/commands/deleteFunctionApp'; -export * from './src/commands/deploy/deploy'; -export * from './src/commands/deploy/verifyAppSettings'; -export * from './src/commands/initProjectForVSCode/initProjectForVSCode'; -export * from './src/constants'; -export * from './src/utils/durableUtils'; -// Export activate/deactivate for main.js -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'; -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'; -export * from './src/utils/fs'; -export * from './src/utils/nonNull'; -export * from './src/utils/nugetUtils'; -export * from './src/utils/parseJson'; -export * from './src/utils/requestUtils'; -export * from './src/utils/venvUtils'; -export * from './src/utils/workspace'; -export * from './src/vsCodeConfig/extensions'; -export * from './src/vsCodeConfig/launch'; -export * from './src/vsCodeConfig/settings'; -export * from './src/vsCodeConfig/tasks'; - -// NOTE: The auto-fix action "source.organizeImports" does weird things with this file, but there doesn't seem to be a way to disable it on a per-file basis so we'll just let it happen diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 40983bbfe..1b09031be 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -41,7 +41,7 @@ export interface IStartFuncProcessResult { * 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. @@ -169,7 +169,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; - while (Date.now() < maxTime) { if (taskError !== undefined) { throw taskError; @@ -200,7 +199,6 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo } } } - } await delay(intervalMs); diff --git a/src/debug/registerFunctionHostDebugView.ts b/src/debug/registerFunctionHostDebugView.ts index f715436d6..509a3e497 100644 --- a/src/debug/registerFunctionHostDebugView.ts +++ b/src/debug/registerFunctionHostDebugView.ts @@ -5,43 +5,20 @@ import { registerCommand, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; +import { getRecentLogsPlainText } from '../funcCoreTools/funcHostErrorUtils'; import { onRunningFuncTasksChanged, refreshFuncHostDebugContext, runningFuncTaskMap, 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'); + return !!node && typeof node === 'object' && (node as IHostTaskNode).kind === 'hostTask'; } 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'); + return !!node && typeof node === 'object' && (node as IHostErrorNode).kind === 'hostError'; } async function tryOpenDebugViewOnFirstFuncHostError(): Promise { @@ -74,16 +51,6 @@ async function tryOpenDebugViewOnFirstFuncHostError(): Promise { } } -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)); -} - export function registerFunctionHostDebugView(context: vscode.ExtensionContext): void { const provider = new FuncHostDebugViewProvider(); @@ -167,7 +134,7 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): errorContext, ].filter((l): l is string => Boolean(l)).join('\n'); - await openCopilotChat(prompt); + await vscode.commands.executeCommand('workbench.action.chat.open', { mode: 'agent', query: prompt }); }); registerCommand('azureFunctions.funcHostDebug.refresh', async (actionContext: IActionContext) => { diff --git a/src/funcCoreTools/funcHostErrorUtils.ts b/src/funcCoreTools/funcHostErrorUtils.ts index 5e5e3950a..3aabaa3a0 100644 --- a/src/funcCoreTools/funcHostErrorUtils.ts +++ b/src/funcCoreTools/funcHostErrorUtils.ts @@ -4,6 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { stripAnsiControlCharacters } from '../utils/ansiUtils'; +import { type IRunningFuncTask } from './funcHostTask'; + +export 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(''); +} + +export function getRecentLogsPlainText(task: IRunningFuncTask | undefined, limit: number = 250): string { + return stripAnsiControlCharacters(getRecentLogs(task, limit)); +} /** * Regex that matches a Functions-host timestamp prefix, e.g. `[2026-03-11T19:57:44.622Z]`. @@ -21,21 +32,6 @@ function normalizeWhitespace(text: string): string { return text.replace(/\s+/g, ' ').trim(); } -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; -} - // Detect red/bright-red foreground in any of the common SGR forms: // Basic 4-bit: \x1b[31m (red) / \x1b[91m (bright red), with optional extra params // 256-color: \x1b[38;5;1m (red) / \x1b[38;5;9m (bright red) @@ -95,65 +91,3 @@ export function addErrorLinesFromChunk(errorLogs: string[], rawChunk: string): b return added; } - -/** - * 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 before = options?.before ?? 5; - const after = options?.after ?? 15; - 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 []; - } - - 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/utils/copilotChat.ts b/src/utils/copilotChat.ts deleted file mode 100644 index 532f5c4fc..000000000 --- a/src/utils/copilotChat.ts +++ /dev/null @@ -1,44 +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 * 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/funcHostErrorContext.test.ts b/test/funcHostErrorContext.test.ts index b56fb9210..4a03092f2 100644 --- a/test/funcHostErrorContext.test.ts +++ b/test/funcHostErrorContext.test.ts @@ -6,26 +6,105 @@ import * as assert from 'assert'; // eslint-disable-next-line no-restricted-imports -import { extractFuncHostErrorContextForErrorMessage, isFuncHostErrorLog } from '../extension.bundle'; +import { addErrorLinesFromChunk, isFuncHostErrorLog } from '../extension.bundle'; -suite('Function host error context extraction', () => { - test('detects red ANSI as error', () => { +suite('isFuncHostErrorLog', () => { + test('detects basic red (31m)', () => { assert.strictEqual(isFuncHostErrorLog('\u001b[31m[Error] Boom\u001b[39m'), true); + }); + + test('detects basic bright red (91m)', () => { + assert.strictEqual(isFuncHostErrorLog('\u001b[91mBright red error\u001b[0m'), true); + }); + + test('detects red with leading params (1;31m)', () => { + assert.strictEqual(isFuncHostErrorLog('\u001b[1;31mBold red\u001b[0m'), true); + }); + + test('detects red with trailing params (31;1m)', () => { + assert.strictEqual(isFuncHostErrorLog('\u001b[31;1mRed bold\u001b[0m'), true); + }); + + test('detects 256-color red (38;5;1m)', () => { + assert.strictEqual(isFuncHostErrorLog('\u001b[38;5;1mRed 256\u001b[m'), true); + }); + + test('detects 256-color bright red (38;5;9m)', () => { + assert.strictEqual(isFuncHostErrorLog('\u001b[38;5;9mBright red 256\u001b[m'), true); + }); + + test('returns false for non-red colors', () => { + assert.strictEqual(isFuncHostErrorLog('\u001b[32mGreen text\u001b[0m'), false); + assert.strictEqual(isFuncHostErrorLog('\u001b[38;5;3mYellow 256\u001b[m'), false); + assert.strictEqual(isFuncHostErrorLog('\u001b[38;5;6mCyan 256\u001b[m'), false); + }); + + test('returns false for plain text', () => { assert.strictEqual(isFuncHostErrorLog('normal log line'), false); }); +}); + +suite('addErrorLinesFromChunk', () => { + test('extracts error entry from chunk with timestamp', () => { + const errorLogs: string[] = []; + const chunk = '[2026-03-11T20:00:00.000Z] \u001b[38;5;9mSomething failed\u001b[m'; + const added = addErrorLinesFromChunk(errorLogs, chunk); + assert.strictEqual(added, true); + assert.strictEqual(errorLogs.length, 1); + assert.ok(errorLogs[0].includes('Something failed')); + }); - test('extractFuncHostErrorContextForErrorMessage returns only the matching red entry', () => { - 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', - ]; + test('skips non-error entries in a mixed chunk', () => { + const errorLogs: string[] = []; + const chunk = [ + '[2026-03-11T20:00:00.000Z] \u001b[38;5;6mInfo message\u001b[m', + '[2026-03-11T20:00:01.000Z] \u001b[38;5;9mError message\u001b[m', + '[2026-03-11T20:00:02.000Z] \u001b[38;5;6mAnother info\u001b[m', + ].join(''); + const added = addErrorLinesFromChunk(errorLogs, chunk); + assert.strictEqual(added, true); + assert.strictEqual(errorLogs.length, 1); + assert.ok(errorLogs[0].includes('Error message')); + assert.ok(!errorLogs[0].includes('Info message')); + }); + + test('deduplicates identical error entries', () => { + const errorLogs: string[] = []; + const chunk = '[2026-03-11T20:00:00.000Z] \u001b[38;5;9mDuplicate error\u001b[m'; + addErrorLinesFromChunk(errorLogs, chunk); + addErrorLinesFromChunk(errorLogs, chunk); + assert.strictEqual(errorLogs.length, 1); + }); + + test('deduplicates entries that differ only in whitespace (terminal reflow)', () => { + const errorLogs: string[] = []; + const chunk1 = '[2026-03-11T20:00:00.000Z] \u001b[38;5;9mException while executing function: Foo\u001b[m'; + const chunk2 = '[2026-03-11T20:00:00.000Z] \u001b[38;5;9mException while\nexecuting function: Foo\u001b[m'; + addErrorLinesFromChunk(errorLogs, chunk1); + addErrorLinesFromChunk(errorLogs, chunk2); + assert.strictEqual(errorLogs.length, 1); + }); + + test('returns false when no new errors are added', () => { + const errorLogs: string[] = []; + const infoChunk = '[2026-03-11T20:00:00.000Z] \u001b[38;5;6mAll good\u001b[m'; + assert.strictEqual(addErrorLinesFromChunk(errorLogs, infoChunk), false); + assert.strictEqual(errorLogs.length, 0); + }); + + test('caps errorLogs at 10 entries', () => { + const errorLogs: string[] = []; + const chunks = Array.from({ length: 15 }, (_, i) => + `[2026-03-11T20:00:${String(i).padStart(2, '0')}.000Z] \u001b[31mError ${i}\u001b[m` + ).join(''); + addErrorLinesFromChunk(errorLogs, chunks); + assert.ok(errorLogs.length <= 10, `Expected at most 10, got ${errorLogs.length}`); + }); - const extracted = extractFuncHostErrorContextForErrorMessage(logs, '[Error] Second', { before: 1, after: 1, max: 250 }); - assert.deepStrictEqual(extracted, [ - '[Error] Second\n', - ]); + test('handles chunk with no timestamps (pre-binding noise)', () => { + const errorLogs: string[] = []; + const chunk = '\u001b[?25l\u001b[38;5;8msome cursor noise\u001b[?25h'; + assert.strictEqual(addErrorLinesFromChunk(errorLogs, chunk), false); + assert.strictEqual(errorLogs.length, 0); }); }); From 70b041489ad82f13179c45b6836e40ffd816a4e8 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 11 Mar 2026 17:06:40 -0700 Subject: [PATCH 43/51] Fix imports in tests --- test/ansiUtils.test.ts | 2 +- test/funcHostErrorContext.test.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/test/ansiUtils.test.ts b/test/ansiUtils.test.ts index e5ab36f32..a4262c037 100644 --- a/test/ansiUtils.test.ts +++ b/test/ansiUtils.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { stripAnsiControlCharacters } from '../extension.bundle'; +import { stripAnsiControlCharacters } from '../src/utils/ansiUtils'; suite('stripAnsiControlCharacters', () => { test('removes CSI color sequences', () => { diff --git a/test/funcHostErrorContext.test.ts b/test/funcHostErrorContext.test.ts index 4a03092f2..6483e29b9 100644 --- a/test/funcHostErrorContext.test.ts +++ b/test/funcHostErrorContext.test.ts @@ -4,9 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { addErrorLinesFromChunk, isFuncHostErrorLog } from '../src/funcCoreTools/funcHostErrorUtils'; -// eslint-disable-next-line no-restricted-imports -import { addErrorLinesFromChunk, isFuncHostErrorLog } from '../extension.bundle'; suite('isFuncHostErrorLog', () => { test('detects basic red (31m)', () => { From 55a50cfd098ecdfc042afff66e83507fa3806a1b Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 11 Mar 2026 17:09:30 -0700 Subject: [PATCH 44/51] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/tree/localProject/LocalProjectTreeItem.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tree/localProject/LocalProjectTreeItem.ts b/src/tree/localProject/LocalProjectTreeItem.ts index e51932cef..81f36f64e 100644 --- a/src/tree/localProject/LocalProjectTreeItem.ts +++ b/src/tree/localProject/LocalProjectTreeItem.ts @@ -59,6 +59,8 @@ 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))); + // onDotnetFuncTaskReady currently emits a TaskScope directly (for back-compat), + // so adapt it to the event-object shape expected by onFuncTaskChanged. this._disposables.push(onDotnetFuncTaskReady(async scope => this.onFuncTaskChanged({ scope }))); this._localFunctionsTreeItem = new LocalFunctionsTreeItem(this); From 2ebe5231bd79d87bb9148c63a3031bf7ff6a9f70 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 12 Mar 2026 16:40:33 -0700 Subject: [PATCH 45/51] PR feedback --- src/commands/pickFuncProcess.ts | 4 ++++ src/extension.ts | 8 ++++---- src/funcCoreTools/funcHostTask.ts | 16 ++++++++++++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 1b09031be..b99a913a3 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -21,6 +21,10 @@ import { getWorkspaceSetting } from '../vsCodeConfig/settings'; const funcTaskReadyEmitter = new vscode.EventEmitter(); export const onDotnetFuncTaskReady = funcTaskReadyEmitter.event; +export function disposeFuncTaskReadyEmitter(): void { + funcTaskReadyEmitter.dispose(); +} + /** * Result returned from starting a function host process via the API. */ diff --git a/src/extension.ts b/src/extension.ts index f562c9395..3f879067c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,7 +24,7 @@ import { createNewProjectInternal } from './commands/createNewProject/createNewP import { deleteFunctionApp } from './commands/deleteFunctionApp'; import { deployProductionSlot } from './commands/deploy/deploy'; import { initProjectForVSCode } from './commands/initProjectForVSCode/initProjectForVSCode'; -import { startFuncProcessFromApi } from './commands/pickFuncProcess'; +import { disposeFuncTaskReadyEmitter, startFuncProcessFromApi } from './commands/pickFuncProcess'; import { registerCommands } from './commands/registerCommands'; import { func } from './constants'; import { BallerinaDebugProvider } from './debug/BallerinaDebugProvider'; @@ -36,7 +36,7 @@ import { PythonDebugProvider } from './debug/PythonDebugProvider'; import { registerFunctionHostDebugView } from './debug/registerFunctionHostDebugView'; import { handleUri } from './downloadAzureProject/handleUri'; import { ext, TemplateSource } from './extensionVariables'; -import { registerFuncHostTaskEvents, terminalEventReader } from './funcCoreTools/funcHostTask'; +import { disposeFuncHostTaskEmitters, registerFuncHostTaskEvents, terminalEventReader } from './funcCoreTools/funcHostTask'; import { validateFuncCoreToolsInstalled } from './funcCoreTools/validateFuncCoreToolsInstalled'; import { validateFuncCoreToolsIsLatest } from './funcCoreTools/validateFuncCoreToolsIsLatest'; import { getResourceGroupsApi } from './getExtensionApi'; @@ -57,8 +57,6 @@ import { listLocalProjects } from './workspace/listLocalProjects'; const emulatorClient = new DockerDurableTaskSchedulerEmulatorClient(new ShellContainerClient()); export async function activateInternal(context: vscode.ExtensionContext, perfStats: { loadStartTime: number; loadEndTime: number }): Promise { - console.log('**********************************************'); - console.log('Activating Azure Functions extension...'); ext.context = context; ext.outputChannel = createAzExtOutputChannel('Azure Functions', ext.prefix); context.subscriptions.push(ext.outputChannel); @@ -227,4 +225,6 @@ export async function activateInternal(context: vscode.ExtensionContext, perfSta export async function deactivateInternal(): Promise { await emulatorClient.disposeAsync(); terminalEventReader?.dispose(); + disposeFuncHostTaskEmitters(); + disposeFuncTaskReadyEmitter(); } diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 15d4ea2e9..63856e330 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -68,8 +68,15 @@ class RunningFunctionTaskMap { 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); - const resolvedBuildPath = buildPath?.replace('${workspaceFolder}', (t.taskExecution.task?.scope as vscode.WorkspaceFolder).uri?.path); + const workspacePath = typeof t.taskExecution.task?.scope === 'object' + ? (t.taskExecution.task.scope as vscode.WorkspaceFolder).uri?.path + : undefined; + const taskDirectory = workspacePath + ? taskExecution.options?.cwd?.replace('${workspaceFolder}', workspacePath) + : taskExecution.options?.cwd; + const resolvedBuildPath = workspacePath + ? buildPath?.replace('${workspaceFolder}', workspacePath) + : buildPath; // When neither cwd is set, both tasks use the default working directory — treat as a match if (!taskDirectory && !resolvedBuildPath) { @@ -112,6 +119,11 @@ export const onFuncTaskStarted = funcTaskStartedEmitter.event; const runningFuncTasksChangedEmitter = new vscode.EventEmitter(); export const onRunningFuncTasksChanged = runningFuncTasksChangedEmitter.event; +export function disposeFuncHostTaskEmitters(): void { + funcTaskStartedEmitter.dispose(); + runningFuncTasksChangedEmitter.dispose(); +} + const funcHostDebugContextKey = 'azureFunctions.funcHostDebugVisible'; const alwaysShowFuncHostDebugViewSetting = 'alwaysShowFuncHostDebugView'; From 5c09c3f365e19259b40e3a5c2b8e25447124274a Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Tue, 24 Mar 2026 15:03:46 -0700 Subject: [PATCH 46/51] Debugger view always visible but collapsed by default --- package.json | 8 ++--- package.nls.json | 1 - src/debug/registerFunctionHostDebugView.ts | 9 ++---- src/funcCoreTools/funcHostTask.ts | 37 ---------------------- 4 files changed, 5 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index 3e9e96b07..941a290c4 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,8 @@ { "id": "azureFunctions.funcHostDebugView", "name": "%azureFunctions.funcHostDebugView.title%", - "when": "!virtualWorkspace && azureFunctions.funcHostDebugVisible", + "when": "!virtualWorkspace", + "visibility": "collapsed", "icon": "resources/azure-functions.svg" } ] @@ -1273,11 +1274,6 @@ "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 ee7d77fc5..96ae2e220 100644 --- a/package.nls.json +++ b/package.nls.json @@ -39,7 +39,6 @@ "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", diff --git a/src/debug/registerFunctionHostDebugView.ts b/src/debug/registerFunctionHostDebugView.ts index 509a3e497..a801a4bcc 100644 --- a/src/debug/registerFunctionHostDebugView.ts +++ b/src/debug/registerFunctionHostDebugView.ts @@ -6,7 +6,7 @@ import { registerCommand, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { getRecentLogsPlainText } from '../funcCoreTools/funcHostErrorUtils'; -import { onRunningFuncTasksChanged, refreshFuncHostDebugContext, runningFuncTaskMap, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; +import { onRunningFuncTasksChanged, runningFuncTaskMap, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; import { localize } from '../localize'; import { stripAnsiControlCharacters } from '../utils/ansiUtils'; import { FuncHostDebugViewProvider, type IHostErrorNode, type IHostTaskNode } from './FunctionHostDebugView'; @@ -41,7 +41,8 @@ async function tryOpenDebugViewOnFirstFuncHostError(): Promise { // Show Run & Debug view (Debug container) so the view (contributed under it) is visible. try { - await vscode.commands.executeCommand('workbench.view.debug'); + // Focus the specific tree view to expand it within the Debug sidebar. + await vscode.commands.executeCommand(`${viewId}.focus`); // Mark as revealed only after the view open attempt, to avoid repeated calls. for (const t of newlyErroredTasks) { t.hasReportedLiveErrors = true; @@ -62,9 +63,6 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): }), ); - // Ensure the context key is correct on activation. - void refreshFuncHostDebugContext(); - registerCommand('azureFunctions.funcHostDebug.clearErrors', async (actionContext: IActionContext) => { actionContext.telemetry.properties.source = 'funcHostDebugView'; for (const folder of vscode.workspace.workspaceFolders ?? []) { @@ -140,6 +138,5 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): registerCommand('azureFunctions.funcHostDebug.refresh', async (actionContext: IActionContext) => { actionContext.telemetry.properties.source = 'funcHostDebugView'; provider.refresh(); - await refreshFuncHostDebugContext(); }); } diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 63856e330..d4eb90aae 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -124,31 +124,6 @@ export function disposeFuncHostTaskEmitters(): void { runningFuncTasksChangedEmitter.dispose(); } -const funcHostDebugContextKey = 'azureFunctions.funcHostDebugVisible'; -const alwaysShowFuncHostDebugViewSetting = 'alwaysShowFuncHostDebugView'; - -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 }); - } - } - } - - return tasks; -} - -async function updateFuncHostDebugContext(): Promise { - const alwaysShow = !!getWorkspaceSetting(alwaysShowFuncHostDebugViewSetting); - await vscode.commands.executeCommand('setContext', funcHostDebugContextKey, alwaysShow || getAllRunningFuncTasks().length > 0); -} - -export async function refreshFuncHostDebugContext(): Promise { - await updateFuncHostDebugContext(); -} - export const buildPathToWorkspaceFolderMap = new Map(); const defaultFuncPort: string = '7071'; @@ -215,16 +190,6 @@ export function registerFuncHostTaskEvents(): void { funcTaskStartedEmitter.fire({ scope: e.execution.task.scope, execution: e.execution.task.execution as vscode.ShellExecution }); runningFuncTasksChangedEmitter.fire(); - await updateFuncHostDebugContext(); - } - }); - - 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(); } }); @@ -242,7 +207,6 @@ export function registerFuncHostTaskEvents(): void { runningFuncTaskMap.delete(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd); runningFuncTasksChangedEmitter.fire(); - await updateFuncHostDebugContext(); } }); @@ -349,7 +313,6 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol } runningFuncTasksChangedEmitter.fire(); - await updateFuncHostDebugContext(); } /** From 9764185e72f1a1acf2435e0d4f2c16aa4f049f30 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 25 Mar 2026 13:12:34 -0700 Subject: [PATCH 47/51] Display stopped hosts as well --- package.json | 21 +++++ package.nls.json | 5 +- src/debug/FunctionHostDebugView.ts | 97 ++++++++++++++++++---- src/debug/registerFunctionHostDebugView.ts | 42 +++++++--- src/funcCoreTools/funcHostErrorUtils.ts | 6 +- src/funcCoreTools/funcHostTask.ts | 41 ++++++++- 6 files changed, 176 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 941a290c4..93977b7c7 100644 --- a/package.json +++ b/package.json @@ -508,6 +508,12 @@ "title": "%azureFunctions.funcHostDebug.askCopilot%", "category": "Azure Functions", "icon": "$(sparkle)" + }, + { + "command": "azureFunctions.funcHostDebug.clearStoppedSessions", + "title": "%azureFunctions.funcHostDebug.clearStoppedSessions%", + "category": "Azure Functions", + "icon": "$(trash)" } ], "submenus": [ @@ -554,6 +560,11 @@ "command": "azureFunctions.funcHostDebug.clearErrors", "when": "view == azureFunctions.funcHostDebugView", "group": "navigation@1" + }, + { + "command": "azureFunctions.funcHostDebug.clearStoppedSessions", + "when": "view == azureFunctions.funcHostDebugView", + "group": "navigation@2" } ], "view/item/context": [ @@ -567,6 +578,16 @@ "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostTask", "group": "inline" }, + { + "command": "azureFunctions.funcHostDebug.showRecentLogs", + "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.stoppedHostTask", + "group": "inline" + }, + { + "command": "azureFunctions.funcHostDebug.copyRecentLogs", + "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.stoppedHostTask", + "group": "inline" + }, { "command": "azureFunctions.funcHostDebug.askCopilot", "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostError", diff --git a/package.nls.json b/package.nls.json index 96ae2e220..68902e5e2 100644 --- a/package.nls.json +++ b/package.nls.json @@ -145,10 +145,11 @@ "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.funcHostDebugView.title": "Function Host Debug", + "azureFunctions.funcHostDebugView.title": "Functions Host Exceptions Debugger", "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.askCopilot": "Ask Copilot" + "azureFunctions.funcHostDebug.askCopilot": "Ask Copilot", + "azureFunctions.funcHostDebug.clearStoppedSessions": "Clear Stopped Sessions" } diff --git a/src/debug/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts index 67ae0fb07..f692a2f81 100644 --- a/src/debug/FunctionHostDebugView.ts +++ b/src/debug/FunctionHostDebugView.ts @@ -4,15 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { runningFuncTaskMap } from '../funcCoreTools/funcHostTask'; +import { runningFuncTaskMap, stoppedFuncTasks, type IStoppedFuncTask } from '../funcCoreTools/funcHostTask'; import { localize } from '../localize'; enum FuncHostDebugContextValue { HostTask = 'azFunc.funcHostDebug.hostTask', + StoppedHostTask = 'azFunc.funcHostDebug.stoppedHostTask', HostError = 'azFunc.funcHostDebug.hostError', } -type FuncHostDebugNode = INoHostNode | IHostTaskNode | IHostErrorNode; +type FuncHostDebugNode = INoHostNode | IHostTaskNode | IStoppedHostNode | IHostErrorNode; interface INoHostNode { kind: 'noHost'; @@ -23,6 +24,12 @@ export interface IHostTaskNode { workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope; cwd?: string; portNumber: string; + startTime: Date; +} + +export interface IStoppedHostNode { + kind: 'stoppedHost'; + stoppedTask: IStoppedFuncTask; } export interface IHostErrorNode { @@ -33,6 +40,28 @@ export interface IHostErrorNode { message: string; } +function formatTimestamp(date: Date): string { + return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function buildHostTooltip(opts: { label: string; scopeLabel: string; portNumber: string; startTime: Date; stopTime?: Date; cwd?: string; pid?: number }): vscode.MarkdownString { + const tooltip = new vscode.MarkdownString(undefined, true); + tooltip.appendMarkdown(`**${opts.label}**\n\n`); + tooltip.appendMarkdown(`- ${localize('funcHostDebug.workspace', 'Workspace')}: ${opts.scopeLabel}\n`); + if (opts.pid !== undefined) { + tooltip.appendMarkdown(`- ${localize('funcHostDebug.pid', 'PID')}: ${opts.pid}\n`); + } + tooltip.appendMarkdown(`- ${localize('funcHostDebug.port', 'Port')}: ${opts.portNumber}\n`); + tooltip.appendMarkdown(`- ${localize('funcHostDebug.started', 'Started')}: ${opts.startTime.toLocaleString()}\n`); + if (opts.stopTime) { + tooltip.appendMarkdown(`- ${localize('funcHostDebug.stopped', 'Stopped')}: ${opts.stopTime.toLocaleString()}\n`); + } + if (opts.cwd) { + tooltip.appendMarkdown(`- ${localize('funcHostDebug.cwd', 'CWD')}: ${opts.cwd}\n`); + } + return tooltip; +} + 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.'); @@ -59,23 +88,35 @@ function getHostTaskTreeItem(element: IHostTaskNode): vscode.TreeItem { 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 tooltip = buildHostTooltip({ label, scopeLabel, portNumber: element.portNumber, startTime: element.startTime, cwd: element.cwd, pid: task?.processId }); const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Expanded); - item.description = scopeLabel; + item.description = `${scopeLabel} - ${formatTimestamp(element.startTime)}`; item.tooltip = tooltip; item.contextValue = FuncHostDebugContextValue.HostTask; item.iconPath = new vscode.ThemeIcon('server-process'); return item; } +function getStoppedHostTreeItem(element: IStoppedHostNode): vscode.TreeItem { + const stopped = element.stoppedTask; + const scopeLabel = typeof stopped.workspaceFolder === 'object' + ? stopped.workspaceFolder.name + : localize('funcHostDebug.globalScope', 'Global'); + + const label = localize('funcHostDebug.stoppedHostLabel', 'Function Host ({0}) — Stopped', stopped.portNumber); + + const tooltip = buildHostTooltip({ label, scopeLabel, portNumber: stopped.portNumber, startTime: stopped.startTime, stopTime: stopped.stopTime, cwd: stopped.cwd }); + + const errorCount = stopped.errorLogs.length; + const item = new vscode.TreeItem(label, errorCount > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None); + item.description = `${scopeLabel} - ${formatTimestamp(stopped.startTime)} → ${formatTimestamp(stopped.stopTime)}`; + item.tooltip = tooltip; + item.contextValue = FuncHostDebugContextValue.StoppedHostTask; + item.iconPath = new vscode.ThemeIcon('debug-stop', new vscode.ThemeColor('disabledForeground')); + return item; +} + export class FuncHostDebugViewProvider implements vscode.TreeDataProvider { private readonly _onDidChangeTreeDataEmitter = new vscode.EventEmitter(); public readonly onDidChangeTreeData = this._onDidChangeTreeDataEmitter.event; @@ -92,6 +133,8 @@ export class FuncHostDebugViewProvider implements vscode.TreeDataProvider ({ + kind: 'hostError', + workspaceFolder: stopped.workspaceFolder, + cwd: stopped.cwd, + portNumber: stopped.portNumber, + message, + })); } else if (element) { return []; } - const hostTasks: IHostTaskNode[] = []; + const nodes: FuncHostDebugNode[] = []; + let hasRunning = false; + // Running sessions first (newest on top by insertion order). 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 }); + nodes.push({ kind: 'hostTask', workspaceFolder: folder, cwd, portNumber: t.portNumber, startTime: t.startTime }); + hasRunning = true; } } - if (hostTasks.length === 0) { - return [{ kind: 'noHost' }]; + // Always show the hint node when no host is actively running. + if (!hasRunning) { + nodes.push({ kind: 'noHost' }); + } + + // Stopped sessions (already newest-first in the array). + for (const stopped of stoppedFuncTasks) { + nodes.push({ kind: 'stoppedHost', stoppedTask: stopped }); } - return hostTasks; + return nodes; } } diff --git a/src/debug/registerFunctionHostDebugView.ts b/src/debug/registerFunctionHostDebugView.ts index a801a4bcc..842103fd5 100644 --- a/src/debug/registerFunctionHostDebugView.ts +++ b/src/debug/registerFunctionHostDebugView.ts @@ -6,10 +6,10 @@ import { registerCommand, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { getRecentLogsPlainText } from '../funcCoreTools/funcHostErrorUtils'; -import { onRunningFuncTasksChanged, runningFuncTaskMap, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; +import { clearStoppedSessions, onRunningFuncTasksChanged, runningFuncTaskMap, stoppedFuncTasks, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; import { localize } from '../localize'; import { stripAnsiControlCharacters } from '../utils/ansiUtils'; -import { FuncHostDebugViewProvider, type IHostErrorNode, type IHostTaskNode } from './FunctionHostDebugView'; +import { FuncHostDebugViewProvider, type IHostErrorNode, type IHostTaskNode, type IStoppedHostNode } from './FunctionHostDebugView'; const viewId = 'azureFunctions.funcHostDebugView'; @@ -17,6 +17,10 @@ function isHostTaskNode(node: unknown): node is IHostTaskNode { return !!node && typeof node === 'object' && (node as IHostTaskNode).kind === 'hostTask'; } +function isStoppedHostNode(node: unknown): node is IStoppedHostNode { + return !!node && typeof node === 'object' && (node as IStoppedHostNode).kind === 'stoppedHost'; +} + function isHostErrorNode(node: unknown): node is IHostErrorNode { return !!node && typeof node === 'object' && (node as IHostErrorNode).kind === 'hostError'; } @@ -77,29 +81,43 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): } } + // Also clear errors from stopped sessions. + for (const s of stoppedFuncTasks) { + s.errorLogs = []; + } + provider.refresh(); }); + registerCommand('azureFunctions.funcHostDebug.clearStoppedSessions', async (actionContext: IActionContext) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + clearStoppedSessions(); + }); + registerCommand('azureFunctions.funcHostDebug.copyRecentLogs', async (actionContext: IActionContext, args: unknown) => { actionContext.telemetry.properties.source = 'funcHostDebugView'; - if (!isHostTaskNode(args)) { - return; + if (isHostTaskNode(args)) { + const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); + const text = getRecentLogsPlainText(task); + await vscode.env.clipboard.writeText(text); + } else if (isStoppedHostNode(args)) { + const text = getRecentLogsPlainText(args.stoppedTask); + await vscode.env.clipboard.writeText(text); } - - const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); - const text = getRecentLogsPlainText(task); - await vscode.env.clipboard.writeText(text); }); registerCommand('azureFunctions.funcHostDebug.showRecentLogs', async (actionContext: IActionContext, args: unknown) => { actionContext.telemetry.properties.source = 'funcHostDebugView'; - if (!isHostTaskNode(args)) { + let text: string | undefined; + if (isHostTaskNode(args)) { + const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); + text = getRecentLogsPlainText(task); + } else if (isStoppedHostNode(args)) { + text = getRecentLogsPlainText(args.stoppedTask); + } else { 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', diff --git a/src/funcCoreTools/funcHostErrorUtils.ts b/src/funcCoreTools/funcHostErrorUtils.ts index 3aabaa3a0..61f9fc6b7 100644 --- a/src/funcCoreTools/funcHostErrorUtils.ts +++ b/src/funcCoreTools/funcHostErrorUtils.ts @@ -4,15 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { stripAnsiControlCharacters } from '../utils/ansiUtils'; -import { type IRunningFuncTask } from './funcHostTask'; +import { type IRunningFuncTask, type IStoppedFuncTask } from './funcHostTask'; -export function getRecentLogs(task: IRunningFuncTask | undefined, limit: number = 250): string { +export function getRecentLogs(task: IRunningFuncTask | IStoppedFuncTask | undefined, limit: number = 250): string { const logs = task?.logs ?? []; const recent = logs.slice(Math.max(0, logs.length - limit)); return recent.join(''); } -export function getRecentLogsPlainText(task: IRunningFuncTask | undefined, limit: number = 250): string { +export function getRecentLogsPlainText(task: IRunningFuncTask | IStoppedFuncTask | undefined, limit: number = 250): string { return stripAnsiControlCharacters(getRecentLogs(task, limit)); } diff --git a/src/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index d4eb90aae..a0d7b7984 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -19,6 +19,7 @@ export interface IRunningFuncTask { taskExecution: vscode.TaskExecution; processId: number; portNumber: string; + startTime: Date; // stream for reading `func host start` output stream: AsyncIterable | undefined; logs: string[]; @@ -39,6 +40,16 @@ export interface IRunningFuncTask { streamAbortController?: AbortController; } +export interface IStoppedFuncTask { + portNumber: string; + startTime: Date; + stopTime: Date; + workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope; + cwd?: string; + logs: string[]; + errorLogs: string[]; +} + export interface IRunningFuncTaskWithScope { scope: vscode.WorkspaceFolder | vscode.TaskScope; task: IRunningFuncTask; @@ -113,6 +124,17 @@ class RunningFunctionTaskMap { export const runningFuncTaskMap: RunningFunctionTaskMap = new RunningFunctionTaskMap(); +/** + * Sessions that have stopped but are preserved in the tree view so users can + * review errors that occurred before the host exited. Newest first. + */ +export const stoppedFuncTasks: IStoppedFuncTask[] = []; + +export function clearStoppedSessions(): void { + stoppedFuncTasks.length = 0; + runningFuncTasksChangedEmitter.fire(); +} + const funcTaskStartedEmitter = new vscode.EventEmitter<{ scope: vscode.WorkspaceFolder | vscode.TaskScope, execution?: vscode.ShellExecution }>(); export const onFuncTaskStarted = funcTaskStartedEmitter.event; @@ -179,6 +201,7 @@ export function registerFuncHostTaskEvents(): void { processId: e.processId, taskExecution: e.execution, portNumber, + startTime: new Date(), stream: latestTerminalShellExecutionEvent?.execution.read(), logs, errorLogs: [], @@ -197,14 +220,28 @@ 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 cwd = (e.execution.task.execution as vscode.ShellExecution).options?.cwd; + const task = runningFuncTaskMap.get(e.execution.task.scope, 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); + // Preserve the session so users can review errors after the host exits. + if (task) { + stoppedFuncTasks.unshift({ + portNumber: task.portNumber, + startTime: task.startTime, + stopTime: new Date(), + workspaceFolder: e.execution.task.scope, + cwd, + logs: task.logs.slice(), + errorLogs: (task.errorLogs ?? []).slice(), + }); + } + + runningFuncTaskMap.delete(e.execution.task.scope, cwd); runningFuncTasksChangedEmitter.fire(); } From bd6ce2f379c46916c8edf64ffeed435e9922fda7 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 25 Mar 2026 15:36:18 -0700 Subject: [PATCH 48/51] Expose ask copilot on top level node --- package.json | 10 +++++ src/debug/registerFunctionHostDebugView.ts | 46 ++++++++++++++++------ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 93977b7c7..16350d187 100644 --- a/package.json +++ b/package.json @@ -593,6 +593,16 @@ "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostError", "group": "1@1" }, + { + "command": "azureFunctions.funcHostDebug.askCopilot", + "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostTask", + "group": "1@1" + }, + { + "command": "azureFunctions.funcHostDebug.askCopilot", + "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.stoppedHostTask", + "group": "1@1" + }, { "command": "azureFunctions.createFunction", "when": "view == azureWorkspace && viewItem =~ /azFuncLocalProject/i", diff --git a/src/debug/registerFunctionHostDebugView.ts b/src/debug/registerFunctionHostDebugView.ts index 842103fd5..eb873b5c6 100644 --- a/src/debug/registerFunctionHostDebugView.ts +++ b/src/debug/registerFunctionHostDebugView.ts @@ -127,27 +127,51 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): registerCommand('azureFunctions.funcHostDebug.askCopilot', async (actionContext: IActionContext, args: unknown) => { actionContext.telemetry.properties.source = 'funcHostDebugView'; - if (!isHostErrorNode(args)) { + + let scopeLabel: string; + let portNumber: string; + let cwd: string | undefined; + let errorOutput: string; + + if (isHostErrorNode(args)) { + scopeLabel = typeof args.workspaceFolder === 'object' + ? args.workspaceFolder.name + : localize('funcHostDebug.globalScope', 'Global'); + portNumber = args.portNumber; + cwd = args.cwd; + errorOutput = stripAnsiControlCharacters(args.message).trim() || args.message; + } else if (isHostTaskNode(args)) { + const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); + scopeLabel = typeof args.workspaceFolder === 'object' + ? args.workspaceFolder.name + : localize('funcHostDebug.globalScope', 'Global'); + portNumber = args.portNumber; + cwd = args.cwd; + const errors = (task?.errorLogs ?? []).map(m => stripAnsiControlCharacters(m).trim()).filter(Boolean); + errorOutput = errors.join('\n\n') || localize('funcHostDebug.noErrors', 'No errors detected.'); + } else if (isStoppedHostNode(args)) { + const stopped = args.stoppedTask; + scopeLabel = typeof stopped.workspaceFolder === 'object' + ? stopped.workspaceFolder.name + : localize('funcHostDebug.globalScope', 'Global'); + portNumber = stopped.portNumber; + cwd = stopped.cwd; + const errors = stopped.errorLogs.map(m => stripAnsiControlCharacters(m).trim()).filter(Boolean); + errorOutput = errors.join('\n\n') || localize('funcHostDebug.noErrors', 'No errors detected.'); + } else { return; } - // 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 - : localize('funcHostDebug.globalScope', 'Global'); - const prompt = [ 'I am debugging an Azure Functions project locally in VS Code.', - `Function Host Port: ${args.portNumber}`, + `Function Host Port: ${portNumber}`, `Workspace: ${scopeLabel}`, - args.cwd ? `CWD: ${args.cwd}` : undefined, + cwd ? `CWD: ${cwd}` : undefined, '', 'The Functions host produced an error. Diagnose the likely cause and suggest concrete next steps to fix it.', '', 'Error output:', - errorContext, + errorOutput, ].filter((l): l is string => Boolean(l)).join('\n'); await vscode.commands.executeCommand('workbench.action.chat.open', { mode: 'agent', query: prompt }); From 3a35411ed9a410ba9cb4e32d278d87c32f378cd2 Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Wed, 25 Mar 2026 17:41:31 -0700 Subject: [PATCH 49/51] Make labels a bit more descriptive --- src/debug/FunctionHostDebugView.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/debug/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts index f692a2f81..ee5732a7f 100644 --- a/src/debug/FunctionHostDebugView.ts +++ b/src/debug/FunctionHostDebugView.ts @@ -86,12 +86,12 @@ function getHostTaskTreeItem(element: IHostTaskNode): vscode.TreeItem { ? element.workspaceFolder.name : localize('funcHostDebug.globalScope', 'Global'); - const label = localize('funcHostDebug.hostLabel', 'Function Host ({0})', element.portNumber); + const label = `${scopeLabel} (${element.portNumber})`; const tooltip = buildHostTooltip({ label, scopeLabel, portNumber: element.portNumber, startTime: element.startTime, cwd: element.cwd, pid: task?.processId }); const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Expanded); - item.description = `${scopeLabel} - ${formatTimestamp(element.startTime)}`; + item.description = formatTimestamp(element.startTime); item.tooltip = tooltip; item.contextValue = FuncHostDebugContextValue.HostTask; item.iconPath = new vscode.ThemeIcon('server-process'); @@ -104,13 +104,13 @@ function getStoppedHostTreeItem(element: IStoppedHostNode): vscode.TreeItem { ? stopped.workspaceFolder.name : localize('funcHostDebug.globalScope', 'Global'); - const label = localize('funcHostDebug.stoppedHostLabel', 'Function Host ({0}) — Stopped', stopped.portNumber); + const label = `${scopeLabel} (${stopped.portNumber}) — Stopped`; const tooltip = buildHostTooltip({ label, scopeLabel, portNumber: stopped.portNumber, startTime: stopped.startTime, stopTime: stopped.stopTime, cwd: stopped.cwd }); const errorCount = stopped.errorLogs.length; const item = new vscode.TreeItem(label, errorCount > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None); - item.description = `${scopeLabel} - ${formatTimestamp(stopped.startTime)} → ${formatTimestamp(stopped.stopTime)}`; + item.description = `${formatTimestamp(stopped.startTime)} → ${formatTimestamp(stopped.stopTime)}`; item.tooltip = tooltip; item.contextValue = FuncHostDebugContextValue.StoppedHostTask; item.iconPath = new vscode.ThemeIcon('debug-stop', new vscode.ThemeColor('disabledForeground')); From ada82e4c1a2379394dcb89cfa244d269babfbcae Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 26 Mar 2026 10:16:49 -0700 Subject: [PATCH 50/51] Refactor into individual tree items --- package.json | 11 -- package.nls.json | 1 - src/debug/FunctionHostDebugView.ts | 170 ++------------------- src/debug/nodes/HostErrorNode.ts | 37 +++++ src/debug/nodes/HostTaskNode.ts | 48 ++++++ src/debug/nodes/NoHostNode.ts | 22 +++ src/debug/nodes/StoppedHostNode.ts | 43 ++++++ src/debug/nodes/funcHostDebugUtils.ts | 35 +++++ src/debug/registerFunctionHostDebugView.ts | 107 ++++++------- 9 files changed, 247 insertions(+), 227 deletions(-) create mode 100644 src/debug/nodes/HostErrorNode.ts create mode 100644 src/debug/nodes/HostTaskNode.ts create mode 100644 src/debug/nodes/NoHostNode.ts create mode 100644 src/debug/nodes/StoppedHostNode.ts create mode 100644 src/debug/nodes/funcHostDebugUtils.ts diff --git a/package.json b/package.json index 16350d187..13d274b2d 100644 --- a/package.json +++ b/package.json @@ -485,12 +485,6 @@ "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%", @@ -556,11 +550,6 @@ "when": "view == azureFunctions.funcHostDebugView", "group": "navigation@1" }, - { - "command": "azureFunctions.funcHostDebug.clearErrors", - "when": "view == azureFunctions.funcHostDebugView", - "group": "navigation@1" - }, { "command": "azureFunctions.funcHostDebug.clearStoppedSessions", "when": "view == azureFunctions.funcHostDebugView", diff --git a/package.nls.json b/package.nls.json index 68902e5e2..03682a1bf 100644 --- a/package.nls.json +++ b/package.nls.json @@ -147,7 +147,6 @@ "azureFunctions.mcpProjectType.SelfHostedMcpServer": "Runs the Functions host in custom-handler mode, forwarding requests to a self-hosted MCP server process.", "azureFunctions.funcHostDebugView.title": "Functions Host Exceptions Debugger", "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.askCopilot": "Ask Copilot", diff --git a/src/debug/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts index ee5732a7f..76fdf2ece 100644 --- a/src/debug/FunctionHostDebugView.ts +++ b/src/debug/FunctionHostDebugView.ts @@ -4,118 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { runningFuncTaskMap, stoppedFuncTasks, type IStoppedFuncTask } from '../funcCoreTools/funcHostTask'; -import { localize } from '../localize'; +import { runningFuncTaskMap, stoppedFuncTasks } from '../funcCoreTools/funcHostTask'; +import { HostErrorNode } from './nodes/HostErrorNode'; +import { HostTaskNode } from './nodes/HostTaskNode'; +import { NoHostNode } from './nodes/NoHostNode'; +import { StoppedHostNode } from './nodes/StoppedHostNode'; -enum FuncHostDebugContextValue { - HostTask = 'azFunc.funcHostDebug.hostTask', - StoppedHostTask = 'azFunc.funcHostDebug.stoppedHostTask', - HostError = 'azFunc.funcHostDebug.hostError', -} - -type FuncHostDebugNode = INoHostNode | IHostTaskNode | IStoppedHostNode | IHostErrorNode; +export { getScopeLabel } from './nodes/funcHostDebugUtils'; +export { HostErrorNode } from './nodes/HostErrorNode'; +export { HostTaskNode } from './nodes/HostTaskNode'; +export { StoppedHostNode } from './nodes/StoppedHostNode'; -interface INoHostNode { - kind: 'noHost'; -} - -export interface IHostTaskNode { - kind: 'hostTask'; - workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope; - cwd?: string; - portNumber: string; - startTime: Date; -} - -export interface IStoppedHostNode { - kind: 'stoppedHost'; - stoppedTask: IStoppedFuncTask; -} - -export interface IHostErrorNode { - kind: 'hostError'; - workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope; - cwd?: string; - portNumber: string; - message: string; -} - -function formatTimestamp(date: Date): string { - return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); -} - -function buildHostTooltip(opts: { label: string; scopeLabel: string; portNumber: string; startTime: Date; stopTime?: Date; cwd?: string; pid?: number }): vscode.MarkdownString { - const tooltip = new vscode.MarkdownString(undefined, true); - tooltip.appendMarkdown(`**${opts.label}**\n\n`); - tooltip.appendMarkdown(`- ${localize('funcHostDebug.workspace', 'Workspace')}: ${opts.scopeLabel}\n`); - if (opts.pid !== undefined) { - tooltip.appendMarkdown(`- ${localize('funcHostDebug.pid', 'PID')}: ${opts.pid}\n`); - } - tooltip.appendMarkdown(`- ${localize('funcHostDebug.port', 'Port')}: ${opts.portNumber}\n`); - tooltip.appendMarkdown(`- ${localize('funcHostDebug.started', 'Started')}: ${opts.startTime.toLocaleString()}\n`); - if (opts.stopTime) { - tooltip.appendMarkdown(`- ${localize('funcHostDebug.stopped', 'Stopped')}: ${opts.stopTime.toLocaleString()}\n`); - } - if (opts.cwd) { - tooltip.appendMarkdown(`- ${localize('funcHostDebug.cwd', 'CWD')}: ${opts.cwd}\n`); - } - return tooltip; -} - -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 = `${scopeLabel} (${element.portNumber})`; - - const tooltip = buildHostTooltip({ label, scopeLabel, portNumber: element.portNumber, startTime: element.startTime, cwd: element.cwd, pid: task?.processId }); - - const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Expanded); - item.description = formatTimestamp(element.startTime); - item.tooltip = tooltip; - item.contextValue = FuncHostDebugContextValue.HostTask; - item.iconPath = new vscode.ThemeIcon('server-process'); - return item; -} - -function getStoppedHostTreeItem(element: IStoppedHostNode): vscode.TreeItem { - const stopped = element.stoppedTask; - const scopeLabel = typeof stopped.workspaceFolder === 'object' - ? stopped.workspaceFolder.name - : localize('funcHostDebug.globalScope', 'Global'); - - const label = `${scopeLabel} (${stopped.portNumber}) — Stopped`; - - const tooltip = buildHostTooltip({ label, scopeLabel, portNumber: stopped.portNumber, startTime: stopped.startTime, stopTime: stopped.stopTime, cwd: stopped.cwd }); - - const errorCount = stopped.errorLogs.length; - const item = new vscode.TreeItem(label, errorCount > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None); - item.description = `${formatTimestamp(stopped.startTime)} → ${formatTimestamp(stopped.stopTime)}`; - item.tooltip = tooltip; - item.contextValue = FuncHostDebugContextValue.StoppedHostTask; - item.iconPath = new vscode.ThemeIcon('debug-stop', new vscode.ThemeColor('disabledForeground')); - return item; -} +export type FuncHostDebugNode = NoHostNode | HostTaskNode | StoppedHostNode | HostErrorNode; export class FuncHostDebugViewProvider implements vscode.TreeDataProvider { private readonly _onDidChangeTreeDataEmitter = new vscode.EventEmitter(); @@ -126,50 +26,12 @@ export class FuncHostDebugViewProvider implements vscode.TreeDataProvider { - if (element?.kind === 'hostTask') { - const task = runningFuncTaskMap.get(element.workspaceFolder, element.cwd); - const errors = task?.errorLogs ?? []; - return errors - .slice() - .reverse() - .map((message): IHostErrorNode => ({ - kind: 'hostError', - workspaceFolder: element.workspaceFolder, - cwd: element.cwd, - portNumber: element.portNumber, - message, - })); - } else if (element?.kind === 'stoppedHost') { - const stopped = element.stoppedTask; - return stopped.errorLogs - .slice() - .reverse() - .map((message): IHostErrorNode => ({ - kind: 'hostError', - workspaceFolder: stopped.workspaceFolder, - cwd: stopped.cwd, - portNumber: stopped.portNumber, - message, - })); - } else if (element) { - return []; + if (element) { + return element.getChildren(); } const nodes: FuncHostDebugNode[] = []; @@ -182,19 +44,19 @@ export class FuncHostDebugViewProvider implements vscode.TreeDataProvider new HostErrorNode(this.workspaceFolder, this.portNumber, message, this.cwd)); + } +} diff --git a/src/debug/nodes/NoHostNode.ts b/src/debug/nodes/NoHostNode.ts new file mode 100644 index 000000000..7c7f769c3 --- /dev/null +++ b/src/debug/nodes/NoHostNode.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +export class NoHostNode { + public readonly kind = 'noHost' as const; + + public getTreeItem(): 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; + } + + public getChildren(): never[] { + return []; + } +} diff --git a/src/debug/nodes/StoppedHostNode.ts b/src/debug/nodes/StoppedHostNode.ts new file mode 100644 index 000000000..49b6ce05e --- /dev/null +++ b/src/debug/nodes/StoppedHostNode.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { type IStoppedFuncTask } from '../../funcCoreTools/funcHostTask'; +import { buildHostTooltip, formatTimestamp, getScopeLabel } from './funcHostDebugUtils'; +import { HostErrorNode } from './HostErrorNode'; + +enum FuncHostDebugContextValue { + StoppedHostTask = 'azFunc.funcHostDebug.stoppedHostTask', +} + +export class StoppedHostNode { + public readonly kind = 'stoppedHost' as const; + + constructor(public readonly stoppedTask: IStoppedFuncTask) { } + + public getTreeItem(): vscode.TreeItem { + const stopped = this.stoppedTask; + const scopeLabel = getScopeLabel(stopped.workspaceFolder); + const label = `${scopeLabel} (${stopped.portNumber}) — Stopped`; + + const tooltip = buildHostTooltip({ label, scopeLabel, portNumber: stopped.portNumber, startTime: stopped.startTime, stopTime: stopped.stopTime, cwd: stopped.cwd }); + + const errorCount = stopped.errorLogs.length; + const item = new vscode.TreeItem(label, errorCount > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None); + item.description = `${formatTimestamp(stopped.startTime)} → ${formatTimestamp(stopped.stopTime)}`; + item.tooltip = tooltip; + item.contextValue = FuncHostDebugContextValue.StoppedHostTask; + item.iconPath = new vscode.ThemeIcon('debug-stop', new vscode.ThemeColor('disabledForeground')); + return item; + } + + public getChildren(): HostErrorNode[] { + const stopped = this.stoppedTask; + return stopped.errorLogs + .slice() + .reverse() + .map((message) => new HostErrorNode(stopped.workspaceFolder, stopped.portNumber, message, stopped.cwd)); + } +} diff --git a/src/debug/nodes/funcHostDebugUtils.ts b/src/debug/nodes/funcHostDebugUtils.ts new file mode 100644 index 000000000..afb7862ad --- /dev/null +++ b/src/debug/nodes/funcHostDebugUtils.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +export function formatTimestamp(date: Date): string { + return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +export function getScopeLabel(scope: vscode.WorkspaceFolder | vscode.TaskScope): string { + return typeof scope === 'object' + ? scope.name + : localize('funcHostDebug.globalScope', 'Global'); +} + +export function buildHostTooltip(opts: { label: string; scopeLabel: string; portNumber: string; startTime: Date; stopTime?: Date; cwd?: string; pid?: number }): vscode.MarkdownString { + const tooltip = new vscode.MarkdownString(undefined, true); + tooltip.appendMarkdown(`**${opts.label}**\n\n`); + tooltip.appendMarkdown(`- ${localize('funcHostDebug.workspace', 'Workspace')}: ${opts.scopeLabel}\n`); + if (opts.pid !== undefined) { + tooltip.appendMarkdown(`- ${localize('funcHostDebug.pid', 'PID')}: ${opts.pid}\n`); + } + tooltip.appendMarkdown(`- ${localize('funcHostDebug.port', 'Port')}: ${opts.portNumber}\n`); + tooltip.appendMarkdown(`- ${localize('funcHostDebug.started', 'Started')}: ${opts.startTime.toLocaleString()}\n`); + if (opts.stopTime) { + tooltip.appendMarkdown(`- ${localize('funcHostDebug.stopped', 'Stopped')}: ${opts.stopTime.toLocaleString()}\n`); + } + if (opts.cwd) { + tooltip.appendMarkdown(`- ${localize('funcHostDebug.cwd', 'CWD')}: ${opts.cwd}\n`); + } + return tooltip; +} diff --git a/src/debug/registerFunctionHostDebugView.ts b/src/debug/registerFunctionHostDebugView.ts index eb873b5c6..8bdfd9181 100644 --- a/src/debug/registerFunctionHostDebugView.ts +++ b/src/debug/registerFunctionHostDebugView.ts @@ -6,23 +6,57 @@ import { registerCommand, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { getRecentLogsPlainText } from '../funcCoreTools/funcHostErrorUtils'; -import { clearStoppedSessions, onRunningFuncTasksChanged, runningFuncTaskMap, stoppedFuncTasks, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; +import { clearStoppedSessions, onRunningFuncTasksChanged, runningFuncTaskMap, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; import { localize } from '../localize'; import { stripAnsiControlCharacters } from '../utils/ansiUtils'; -import { FuncHostDebugViewProvider, type IHostErrorNode, type IHostTaskNode, type IStoppedHostNode } from './FunctionHostDebugView'; +import { FuncHostDebugViewProvider, HostErrorNode, HostTaskNode, StoppedHostNode } from './FunctionHostDebugView'; +import { getScopeLabel } from './nodes/funcHostDebugUtils'; const viewId = 'azureFunctions.funcHostDebugView'; -function isHostTaskNode(node: unknown): node is IHostTaskNode { - return !!node && typeof node === 'object' && (node as IHostTaskNode).kind === 'hostTask'; +function isHostTaskNode(node: unknown): node is HostTaskNode { + return node instanceof HostTaskNode; } -function isStoppedHostNode(node: unknown): node is IStoppedHostNode { - return !!node && typeof node === 'object' && (node as IStoppedHostNode).kind === 'stoppedHost'; +function isStoppedHostNode(node: unknown): node is StoppedHostNode { + return node instanceof StoppedHostNode; } -function isHostErrorNode(node: unknown): node is IHostErrorNode { - return !!node && typeof node === 'object' && (node as IHostErrorNode).kind === 'hostError'; +function isHostErrorNode(node: unknown): node is HostErrorNode { + return node instanceof HostErrorNode; +} + +function formatErrorLogs(errorLogs: string[]): string { + return errorLogs.map(m => stripAnsiControlCharacters(m).trim()).filter(Boolean).join('\n\n') + || localize('funcHostDebug.noErrors', 'No errors detected.'); +} + +function getNodeContext(args: unknown): { scopeLabel: string; portNumber: string; cwd?: string; errorOutput: string } | undefined { + if (isHostErrorNode(args)) { + return { + scopeLabel: getScopeLabel(args.workspaceFolder), + portNumber: args.portNumber, + cwd: args.cwd, + errorOutput: stripAnsiControlCharacters(args.message).trim() || args.message, + }; + } else if (isHostTaskNode(args)) { + const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); + return { + scopeLabel: getScopeLabel(args.workspaceFolder), + portNumber: args.portNumber, + cwd: args.cwd, + errorOutput: formatErrorLogs(task?.errorLogs ?? []), + }; + } else if (isStoppedHostNode(args)) { + const stopped = args.stoppedTask; + return { + scopeLabel: getScopeLabel(stopped.workspaceFolder), + portNumber: stopped.portNumber, + cwd: stopped.cwd, + errorOutput: formatErrorLogs(stopped.errorLogs), + }; + } + return undefined; } async function tryOpenDebugViewOnFirstFuncHostError(): Promise { @@ -67,28 +101,6 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): }), ); - registerCommand('azureFunctions.funcHostDebug.clearErrors', async (actionContext: IActionContext) => { - actionContext.telemetry.properties.source = 'funcHostDebugView'; - 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 = []; - } - } - } - - // Also clear errors from stopped sessions. - for (const s of stoppedFuncTasks) { - s.errorLogs = []; - } - - provider.refresh(); - }); - registerCommand('azureFunctions.funcHostDebug.clearStoppedSessions', async (actionContext: IActionContext) => { actionContext.telemetry.properties.source = 'funcHostDebugView'; clearStoppedSessions(); @@ -128,40 +140,13 @@ export function registerFunctionHostDebugView(context: vscode.ExtensionContext): registerCommand('azureFunctions.funcHostDebug.askCopilot', async (actionContext: IActionContext, args: unknown) => { actionContext.telemetry.properties.source = 'funcHostDebugView'; - let scopeLabel: string; - let portNumber: string; - let cwd: string | undefined; - let errorOutput: string; - - if (isHostErrorNode(args)) { - scopeLabel = typeof args.workspaceFolder === 'object' - ? args.workspaceFolder.name - : localize('funcHostDebug.globalScope', 'Global'); - portNumber = args.portNumber; - cwd = args.cwd; - errorOutput = stripAnsiControlCharacters(args.message).trim() || args.message; - } else if (isHostTaskNode(args)) { - const task = runningFuncTaskMap.get(args.workspaceFolder, args.cwd); - scopeLabel = typeof args.workspaceFolder === 'object' - ? args.workspaceFolder.name - : localize('funcHostDebug.globalScope', 'Global'); - portNumber = args.portNumber; - cwd = args.cwd; - const errors = (task?.errorLogs ?? []).map(m => stripAnsiControlCharacters(m).trim()).filter(Boolean); - errorOutput = errors.join('\n\n') || localize('funcHostDebug.noErrors', 'No errors detected.'); - } else if (isStoppedHostNode(args)) { - const stopped = args.stoppedTask; - scopeLabel = typeof stopped.workspaceFolder === 'object' - ? stopped.workspaceFolder.name - : localize('funcHostDebug.globalScope', 'Global'); - portNumber = stopped.portNumber; - cwd = stopped.cwd; - const errors = stopped.errorLogs.map(m => stripAnsiControlCharacters(m).trim()).filter(Boolean); - errorOutput = errors.join('\n\n') || localize('funcHostDebug.noErrors', 'No errors detected.'); - } else { + const ctx = getNodeContext(args); + if (!ctx) { return; } + const { scopeLabel, portNumber, cwd, errorOutput } = ctx; + const prompt = [ 'I am debugging an Azure Functions project locally in VS Code.', `Function Host Port: ${portNumber}`, From 8d184122a91a5794c36696e52a2c0d6179fd165e Mon Sep 17 00:00:00 2001 From: Nathan Turinski Date: Thu, 26 Mar 2026 10:29:20 -0700 Subject: [PATCH 51/51] Remove enums that are not shared --- src/debug/nodes/HostErrorNode.ts | 6 +----- src/debug/nodes/HostTaskNode.ts | 6 +----- src/debug/nodes/StoppedHostNode.ts | 6 +----- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/debug/nodes/HostErrorNode.ts b/src/debug/nodes/HostErrorNode.ts index cf61b2d29..9be506516 100644 --- a/src/debug/nodes/HostErrorNode.ts +++ b/src/debug/nodes/HostErrorNode.ts @@ -6,10 +6,6 @@ import * as vscode from 'vscode'; import { localize } from '../../localize'; -enum FuncHostDebugContextValue { - HostError = 'azFunc.funcHostDebug.hostError', -} - export class HostErrorNode { public readonly kind = 'hostError' as const; @@ -27,7 +23,7 @@ export class HostErrorNode { const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); item.iconPath = new vscode.ThemeIcon('error'); item.tooltip = this.message; - item.contextValue = FuncHostDebugContextValue.HostError; + item.contextValue = 'azFunc.funcHostDebug.hostError'; return item; } diff --git a/src/debug/nodes/HostTaskNode.ts b/src/debug/nodes/HostTaskNode.ts index 62a3d6fb3..379853ed8 100644 --- a/src/debug/nodes/HostTaskNode.ts +++ b/src/debug/nodes/HostTaskNode.ts @@ -8,10 +8,6 @@ import { runningFuncTaskMap } from '../../funcCoreTools/funcHostTask'; import { buildHostTooltip, formatTimestamp, getScopeLabel } from './funcHostDebugUtils'; import { HostErrorNode } from './HostErrorNode'; -enum FuncHostDebugContextValue { - HostTask = 'azFunc.funcHostDebug.hostTask', -} - export class HostTaskNode { public readonly kind = 'hostTask' as const; @@ -32,7 +28,7 @@ export class HostTaskNode { const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Expanded); item.description = formatTimestamp(this.startTime); item.tooltip = tooltip; - item.contextValue = FuncHostDebugContextValue.HostTask; + item.contextValue = 'azFunc.funcHostDebug.hostTask'; item.iconPath = new vscode.ThemeIcon('server-process'); return item; } diff --git a/src/debug/nodes/StoppedHostNode.ts b/src/debug/nodes/StoppedHostNode.ts index 49b6ce05e..67f16872f 100644 --- a/src/debug/nodes/StoppedHostNode.ts +++ b/src/debug/nodes/StoppedHostNode.ts @@ -8,10 +8,6 @@ import { type IStoppedFuncTask } from '../../funcCoreTools/funcHostTask'; import { buildHostTooltip, formatTimestamp, getScopeLabel } from './funcHostDebugUtils'; import { HostErrorNode } from './HostErrorNode'; -enum FuncHostDebugContextValue { - StoppedHostTask = 'azFunc.funcHostDebug.stoppedHostTask', -} - export class StoppedHostNode { public readonly kind = 'stoppedHost' as const; @@ -28,7 +24,7 @@ export class StoppedHostNode { const item = new vscode.TreeItem(label, errorCount > 0 ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None); item.description = `${formatTimestamp(stopped.startTime)} → ${formatTimestamp(stopped.stopTime)}`; item.tooltip = tooltip; - item.contextValue = FuncHostDebugContextValue.StoppedHostTask; + item.contextValue = 'azFunc.funcHostDebug.stoppedHostTask'; item.iconPath = new vscode.ThemeIcon('debug-stop', new vscode.ThemeColor('disabledForeground')); return item; }