diff --git a/extension.bundle.ts b/extension.bundle.ts index 8c4c823f2..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'; @@ -40,6 +41,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 5cf12813a..f6fa9a531 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.clearErrors", + "title": "%azureFunctions.funcHostDebug.clearErrors%", + "category": "Azure Functions", + "icon": "$(clear-all)" + }, + { + "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.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" + }, + { + "command": "azureFunctions.funcHostDebug.clearErrors", + "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.askCopilot", + "when": "view == azureFunctions.funcHostDebugView && viewItem == azFunc.funcHostDebug.hostError", + "group": "1@1" + }, { "command": "azureFunctions.createFunction", "when": "view == azureWorkspace && viewItem =~ /azFuncLocalProject/i", @@ -947,6 +1012,22 @@ { "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.askCopilot", + "when": "never" } ], "editor/context": [ @@ -990,6 +1071,12 @@ "properties": { "command": { "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -1186,6 +1273,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 d53bd4540..ee7d77fc5 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", @@ -144,5 +145,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.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/commands/CommandAttributes.ts b/src/commands/CommandAttributes.ts index f0f961bad..991e1c3ae 100644 --- a/src/commands/CommandAttributes.ts +++ b/src/commands/CommandAttributes.ts @@ -30,5 +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.", ], }; - } diff --git a/src/commands/pickFuncProcess.ts b/src/commands/pickFuncProcess.ts index 9fe2417d9..e403c6d96 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -21,15 +21,44 @@ 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 }> { - const result = { +): Promise { + const result: IStartFuncProcessResult = { processId: '', success: false, - error: '' + error: '', + stream: undefined }; let funcHostStartCmd: string = 'func host start'; @@ -66,6 +95,7 @@ export async function startFuncProcessFromApi( const taskInfo = await startFuncTask(context, workspaceFolder, buildPath, funcTask); result.processId = await pickChildProcess(taskInfo); result.success = true; + result.stream = taskInfo.stream; } catch (err) { const pError = parseError(err); result.error = pError.message; @@ -140,6 +170,7 @@ 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; @@ -171,6 +202,7 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo } } } + } await delay(intervalMs); diff --git a/src/debug/FuncTaskProvider.ts b/src/debug/FuncTaskProvider.ts index e583003e9..ca24bfe81 100644 --- a/src/debug/FuncTaskProvider.ts +++ b/src/debug/FuncTaskProvider.ts @@ -104,6 +104,11 @@ export class FuncTaskProvider implements TaskProvider { private async createTask(context: IActionContext, command: string, folder: WorkspaceFolder, projectRoot: string | undefined, language: string | undefined, definition?: TaskDefinition): Promise { const funcCliPath = await getFuncCliPath(context, folder); + const args = (definition?.args || []) as string[]; + if (args.length > 0) { + command = `${command} ${args.join(' ')}`; + } + let commandLine: string = `${funcCliPath} ${command}`; if (language === ProjectLanguage.Python) { commandLine = venvUtils.convertToVenvCommand(commandLine, folder.uri.fsPath); diff --git a/src/debug/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts new file mode 100644 index 000000000..b70144b08 --- /dev/null +++ b/src/debug/FunctionHostDebugView.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { runningFuncTaskMap } from '../funcCoreTools/funcHostTask'; +import { localize } from '../localize'; + +enum FuncHostDebugContextValue { + HostTask = 'azFunc.funcHostDebug.hostTask', + HostError = 'azFunc.funcHostDebug.hostError', +} + +type FuncHostDebugNode = INoHostNode | IHostTaskNode | IHostErrorNode; + +interface INoHostNode { + kind: 'noHost'; +} + +export interface IHostTaskNode { + kind: 'hostTask'; + workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope; + cwd?: string; + portNumber: string; +} + +export interface IHostErrorNode { + kind: 'hostError'; + workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope; + cwd?: string; + portNumber: string; + 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; + + public refresh(): void { + this._onDidChangeTreeDataEmitter.fire(undefined); + } + + public getTreeItem(element: FuncHostDebugNode): vscode.TreeItem { + switch (element.kind) { + case 'noHost': + return getNoHostTreeItem(); + case 'hostError': + return getHostErrorTreeItem(element); + case 'hostTask': + return getHostTaskTreeItem(element); + default: + // Exhaustive check + return getNoHostTreeItem(); + } + } + + 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 }); + } + } + + if (hostTasks.length === 0) { + return [{ kind: 'noHost' }]; + } + + return hostTasks; + } +} diff --git a/src/debug/registerFunctionHostDebugView.ts b/src/debug/registerFunctionHostDebugView.ts new file mode 100644 index 000000000..f715436d6 --- /dev/null +++ b/src/debug/registerFunctionHostDebugView.ts @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'); +} + +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)); +} + +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.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 = []; + } + } + } + + provider.refresh(); + }); + + 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); + }); + + 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; + } + + // 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}`, + `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:', + 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 47be7118f..3bd72c693 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,9 +26,10 @@ 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 } from './funcCoreTools/funcHostTask'; +import { registerFuncHostTaskEvents, terminalEventReader } from './funcCoreTools/funcHostTask'; import { validateFuncCoreToolsInstalled } from './funcCoreTools/validateFuncCoreToolsInstalled'; import { validateFuncCoreToolsIsLatest } from './funcCoreTools/validateFuncCoreToolsIsLatest'; import { getResourceGroupsApi } from './getExtensionApi'; @@ -93,6 +94,7 @@ export async function activateInternal(context: vscode.ExtensionContext, perfSta }); registerFuncHostTaskEvents(); + registerFunctionHostDebugView(context); const nodeDebugProvider: NodeDebugProvider = new NodeDebugProvider(); const pythonDebugProvider: PythonDebugProvider = new PythonDebugProvider(); @@ -154,4 +156,5 @@ export async function activateInternal(context: vscode.ExtensionContext, perfSta export async function deactivateInternal(): Promise { await emulatorClient.disposeAsync(); + terminalEventReader?.dispose(); } diff --git a/src/funcCoreTools/funcHostErrorUtils.ts b/src/funcCoreTools/funcHostErrorUtils.ts new file mode 100644 index 000000000..8d343f150 --- /dev/null +++ b/src/funcCoreTools/funcHostErrorUtils.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * 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)/; + +export function isFuncHostErrorLog(log: string): boolean { + return redAnsiRegex.test(log); +} + +/** + * 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/funcCoreTools/funcHostTask.ts b/src/funcCoreTools/funcHostTask.ts index 26557be2e..107b8d5fa 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -10,13 +10,58 @@ 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'; export interface IRunningFuncTask { taskExecution: vscode.TaskExecution; processId: number; portNumber: string; + // 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 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; + /** + * 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 { + 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 { @@ -75,34 +120,166 @@ 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; +const runningFuncTasksChangedEmitter = new vscode.EventEmitter(); +export const onRunningFuncTasksChanged = runningFuncTasksChangedEmitter.event; + +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'; +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) => { + /** + * Only store terminal events for func host start commands to avoid race conditions + * where a non-func terminal opened just before the func task starts could be incorrectly captured. + * */ + if (isFuncShellEvent(terminalShellExecEvent)) { + latestTerminalShellExecutionEvent = terminalShellExecEvent; + } + }); registerEvent('azureFunctions.onDidStartTask', vscode.tasks.onDidStartTaskProcess, async (context: IActionContext, e: vscode.TaskProcessStartEvent) => { context.errorHandling.suppressDisplay = true; context.telemetry.suppressIfSuccessful = true; + + if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) { const portNumber = await getFuncPortFromTaskOrProject(context, e.execution.task, e.execution.task.scope); - const runningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber }; + const logs: string[] = []; + const runningFuncTask: IRunningFuncTask = { + processId: e.processId, + taskExecution: e.execution, + portNumber, + stream: latestTerminalShellExecutionEvent?.execution.read(), + logs, + errorLogs: [], + hasReportedLiveErrors: false, + streamAbortController: new AbortController(), + }; + 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 }); + + 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(); } }); - 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 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(); + await updateFuncHostDebugContext(); + } + }); + + 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; + } + + const maxLogEntries = 1000; + + try { + for await (const chunk of task.stream ?? []) { + // Check if the stream iteration should be aborted + if (task.streamAbortController?.signal.aborted) { + break; + } + + 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); } }); @@ -128,7 +305,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); - } }); } @@ -146,7 +322,7 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol for (const runningFuncTaskItem of runningFuncTask) { if (!runningFuncTaskItem) break; if (terminate) { - runningFuncTaskItem.taskExecution.terminate() + runningFuncTaskItem.taskExecution.terminate(); } else { // Try to find the real func process by port first, fall back to shell PID await killFuncProcessByPortOrPid(runningFuncTaskItem, workspaceFolder); @@ -161,6 +337,9 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol if (killAll) { runningFuncTaskMap.delete(workspaceFolder); } + + runningFuncTasksChangedEmitter.fire(); + await updateFuncHostDebugContext(); } /** diff --git a/src/tree/localProject/LocalProjectTreeItem.ts b/src/tree/localProject/LocalProjectTreeItem.ts index ab0f4c35e..e4c75b773 100644 --- a/src/tree/localProject/LocalProjectTreeItem.ts +++ b/src/tree/localProject/LocalProjectTreeItem.ts @@ -58,8 +58,8 @@ 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(onDotnetFuncTaskReady(async scope => this.onFuncTaskChanged({ scope }))); this._localFunctionsTreeItem = new LocalFunctionsTreeItem(this); this._localSettingsTreeItem = new AppSettingsTreeItem(this, new LocalSettingsClientProvider(this.workspaceFolder), ext.prefix, { @@ -123,9 +123,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); diff --git a/src/utils/ansiUtils.ts b/src/utils/ansiUtils.ts new file mode 100644 index 000000000..5a0d9d254 --- /dev/null +++ b/src/utils/ansiUtils.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * 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..532f5c4fc --- /dev/null +++ b/src/utils/copilotChat.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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..b56fb9210 --- /dev/null +++ b/test/funcHostErrorContext.test.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { extractFuncHostErrorContextForErrorMessage, isFuncHostErrorLog } from '../extension.bundle'; + +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('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', + ]; + + const extracted = extractFuncHostErrorContextForErrorMessage(logs, '[Error] Second', { before: 1, after: 1, max: 250 }); + assert.deepStrictEqual(extracted, [ + '[Error] Second\n', + ]); + }); +});