diff --git a/package-lock.json b/package-lock.json index 96b802834..12cfb574d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1826,6 +1826,7 @@ "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" }, @@ -2489,7 +2490,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==", - "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3050,9 +3051,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3134,7 +3135,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, - "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", @@ -3173,7 +3173,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, - "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3418,6 +3418,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", @@ -3646,6 +3647,7 @@ "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.5", @@ -3663,6 +3665,7 @@ "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@azure/identity": "^4.1.0", "@secretlint/node": "^10.1.2", @@ -4114,7 +4117,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, - "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4158,7 +4161,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, - "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4480,7 +4483,7 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4521,6 +4524,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" }, @@ -4713,6 +4717,7 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5607,6 +5612,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5648,6 +5654,7 @@ "integrity": "sha512-Bk66jpevTcV8KMFzZI1P7MZKZ+uDcrZm2G2egZ2jNIvVnivDpodZI+/KnpL3Jnap0PBdIHU7HwFGB8r+vV5CVw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.1.2", "chokidar": "^3.5.3", @@ -5721,7 +5728,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, - "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5761,15 +5768,7 @@ "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-scope": { @@ -7737,6 +7736,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", @@ -10088,6 +10088,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10175,7 +10176,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/tsx": { "version": "4.21.0", @@ -10262,6 +10264,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10422,6 +10425,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" }, @@ -10533,7 +10537,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz", "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "dev": true, - "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/package.json b/package.json index f00b3bc56..13d274b2d 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,17 @@ ] } }, + "views": { + "debug": [ + { + "id": "azureFunctions.funcHostDebugView", + "name": "%azureFunctions.funcHostDebugView.title%", + "when": "!virtualWorkspace", + "visibility": "collapsed", + "icon": "resources/azure-functions.svg" + } + ] + }, "commands": [ { "command": "azureFunctions.addBinding", @@ -467,6 +478,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.askCopilot", + "title": "%azureFunctions.funcHostDebug.askCopilot%", + "category": "Azure Functions", + "icon": "$(sparkle)" + }, + { + "command": "azureFunctions.funcHostDebug.clearStoppedSessions", + "title": "%azureFunctions.funcHostDebug.clearStoppedSessions%", + "category": "Azure Functions", + "icon": "$(trash)" } ], "submenus": [ @@ -503,9 +544,54 @@ "submenu": "azureFunctions.submenus.workspaceActions", "when": "view == azureWorkspace", "group": "navigation@1" + }, + { + "command": "azureFunctions.funcHostDebug.refresh", + "when": "view == azureFunctions.funcHostDebugView", + "group": "navigation@1" + }, + { + "command": "azureFunctions.funcHostDebug.clearStoppedSessions", + "when": "view == azureFunctions.funcHostDebugView", + "group": "navigation@2" } ], "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.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", + "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", @@ -947,6 +1033,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 +1092,12 @@ "properties": { "command": { "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } } } } diff --git a/package.nls.json b/package.nls.json index d53bd4540..03682a1bf 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": "Functions Host Exceptions Debugger", + "azureFunctions.funcHostDebug.refresh": "Refresh", + "azureFunctions.funcHostDebug.showRecentLogs": "Show Recent Host Logs", + "azureFunctions.funcHostDebug.copyRecentLogs": "Copy Recent Host Logs", + "azureFunctions.funcHostDebug.askCopilot": "Ask Copilot", + "azureFunctions.funcHostDebug.clearStoppedSessions": "Clear Stopped Sessions" } 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 0535f054f..b99a913a3 100644 --- a/src/commands/pickFuncProcess.ts +++ b/src/commands/pickFuncProcess.ts @@ -21,15 +21,48 @@ 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. + */ +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 +99,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; diff --git a/src/debug/FuncTaskProvider.ts b/src/debug/FuncTaskProvider.ts index 68f20e143..0174570c8 100644 --- a/src/debug/FuncTaskProvider.ts +++ b/src/debug/FuncTaskProvider.ts @@ -104,10 +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); - let commandLine: string = `${funcCliPath} ${command}`; - if (language === ProjectLanguage.Python) { - commandLine = venvUtils.convertToVenvCommand(commandLine, folder.uri.fsPath); - } + const definitionArgs = (definition?.args || []) as string[]; + + // 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; @@ -132,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 { diff --git a/src/debug/FunctionHostDebugView.ts b/src/debug/FunctionHostDebugView.ts new file mode 100644 index 000000000..76fdf2ece --- /dev/null +++ b/src/debug/FunctionHostDebugView.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * 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, stoppedFuncTasks } from '../funcCoreTools/funcHostTask'; +import { HostErrorNode } from './nodes/HostErrorNode'; +import { HostTaskNode } from './nodes/HostTaskNode'; +import { NoHostNode } from './nodes/NoHostNode'; +import { StoppedHostNode } from './nodes/StoppedHostNode'; + +export { getScopeLabel } from './nodes/funcHostDebugUtils'; +export { HostErrorNode } from './nodes/HostErrorNode'; +export { HostTaskNode } from './nodes/HostTaskNode'; +export { StoppedHostNode } from './nodes/StoppedHostNode'; + +export type FuncHostDebugNode = NoHostNode | HostTaskNode | StoppedHostNode | HostErrorNode; + +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 { + return element.getTreeItem(); + } + + public async getChildren(element?: FuncHostDebugNode): Promise { + if (element) { + return element.getChildren(); + } + + 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; + nodes.push(new HostTaskNode(folder, t.portNumber, t.startTime, cwd)); + hasRunning = true; + } + } + + // Always show the hint node when no host is actively running. + if (!hasRunning) { + nodes.push(new NoHostNode()); + } + + // Stopped sessions (already newest-first in the array). + for (const stopped of stoppedFuncTasks) { + nodes.push(new StoppedHostNode(stopped)); + } + + return nodes; + } +} diff --git a/src/debug/nodes/HostErrorNode.ts b/src/debug/nodes/HostErrorNode.ts new file mode 100644 index 000000000..9be506516 --- /dev/null +++ b/src/debug/nodes/HostErrorNode.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * 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 HostErrorNode { + public readonly kind = 'hostError' as const; + + constructor( + public readonly workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope, + public readonly portNumber: string, + public readonly message: string, + public readonly cwd?: string, + ) { } + + public getTreeItem(): vscode.TreeItem { + const firstLine = this.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 = this.message; + item.contextValue = 'azFunc.funcHostDebug.hostError'; + return item; + } + + public getChildren(): never[] { + return []; + } +} diff --git a/src/debug/nodes/HostTaskNode.ts b/src/debug/nodes/HostTaskNode.ts new file mode 100644 index 000000000..379853ed8 --- /dev/null +++ b/src/debug/nodes/HostTaskNode.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 { runningFuncTaskMap } from '../../funcCoreTools/funcHostTask'; +import { buildHostTooltip, formatTimestamp, getScopeLabel } from './funcHostDebugUtils'; +import { HostErrorNode } from './HostErrorNode'; + +export class HostTaskNode { + public readonly kind = 'hostTask' as const; + + constructor( + public readonly workspaceFolder: vscode.WorkspaceFolder | vscode.TaskScope, + public readonly portNumber: string, + public readonly startTime: Date, + public readonly cwd?: string, + ) { } + + public getTreeItem(): vscode.TreeItem { + const task = runningFuncTaskMap.get(this.workspaceFolder, this.cwd); + const scopeLabel = getScopeLabel(this.workspaceFolder); + const label = `${scopeLabel} (${this.portNumber})`; + + const tooltip = buildHostTooltip({ label, scopeLabel, portNumber: this.portNumber, startTime: this.startTime, cwd: this.cwd, pid: task?.processId }); + + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Expanded); + item.description = formatTimestamp(this.startTime); + item.tooltip = tooltip; + item.contextValue = 'azFunc.funcHostDebug.hostTask'; + item.iconPath = new vscode.ThemeIcon('server-process'); + return item; + } + + public getChildren(): HostErrorNode[] { + const task = runningFuncTaskMap.get(this.workspaceFolder, this.cwd); + const errors = task?.errorLogs ?? []; + return errors + .slice() + .reverse() + .map((message) => 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..67f16872f --- /dev/null +++ b/src/debug/nodes/StoppedHostNode.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +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 = 'azFunc.funcHostDebug.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 new file mode 100644 index 000000000..8bdfd9181 --- /dev/null +++ b/src/debug/registerFunctionHostDebugView.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { getRecentLogsPlainText } from '../funcCoreTools/funcHostErrorUtils'; +import { clearStoppedSessions, onRunningFuncTasksChanged, runningFuncTaskMap, type IRunningFuncTask } from '../funcCoreTools/funcHostTask'; +import { localize } from '../localize'; +import { stripAnsiControlCharacters } from '../utils/ansiUtils'; +import { FuncHostDebugViewProvider, HostErrorNode, HostTaskNode, StoppedHostNode } from './FunctionHostDebugView'; +import { getScopeLabel } from './nodes/funcHostDebugUtils'; + +const viewId = 'azureFunctions.funcHostDebugView'; + +function isHostTaskNode(node: unknown): node is HostTaskNode { + return node instanceof HostTaskNode; +} + +function isStoppedHostNode(node: unknown): node is StoppedHostNode { + return node instanceof StoppedHostNode; +} + +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 { + 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 { + // 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; + } + } catch { + // If this fails, leave flags untouched so we can try again later. + } +} + +export function registerFunctionHostDebugView(context: vscode.ExtensionContext): void { + const provider = new FuncHostDebugViewProvider(); + + context.subscriptions.push( + vscode.window.registerTreeDataProvider(viewId, provider), + onRunningFuncTasksChanged(() => { + provider.refresh(); + void tryOpenDebugViewOnFirstFuncHostError(); + }), + ); + + 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)) { + 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); + } + }); + + registerCommand('azureFunctions.funcHostDebug.showRecentLogs', async (actionContext: IActionContext, args: unknown) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + 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 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'; + + 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}`, + `Workspace: ${scopeLabel}`, + cwd ? `CWD: ${cwd}` : undefined, + '', + 'The Functions host produced an error. Diagnose the likely cause and suggest concrete next steps to fix it.', + '', + 'Error output:', + errorOutput, + ].filter((l): l is string => Boolean(l)).join('\n'); + + await vscode.commands.executeCommand('workbench.action.chat.open', { mode: 'agent', query: prompt }); + }); + + registerCommand('azureFunctions.funcHostDebug.refresh', async (actionContext: IActionContext) => { + actionContext.telemetry.properties.source = 'funcHostDebugView'; + provider.refresh(); + }); +} diff --git a/src/extension.ts b/src/extension.ts index 88be5e363..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'; @@ -33,9 +33,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, TemplateSource } from './extensionVariables'; -import { registerFuncHostTaskEvents } from './funcCoreTools/funcHostTask'; +import { disposeFuncHostTaskEmitters, registerFuncHostTaskEvents, terminalEventReader } from './funcCoreTools/funcHostTask'; import { validateFuncCoreToolsInstalled } from './funcCoreTools/validateFuncCoreToolsInstalled'; import { validateFuncCoreToolsIsLatest } from './funcCoreTools/validateFuncCoreToolsIsLatest'; import { getResourceGroupsApi } from './getExtensionApi'; @@ -56,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); @@ -103,6 +102,7 @@ export async function activateInternal(context: vscode.ExtensionContext, perfSta }); registerFuncHostTaskEvents(); + registerFunctionHostDebugView(context); const nodeDebugProvider: NodeDebugProvider = new NodeDebugProvider(); const pythonDebugProvider: PythonDebugProvider = new PythonDebugProvider(); @@ -224,4 +224,7 @@ 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/funcHostErrorUtils.ts b/src/funcCoreTools/funcHostErrorUtils.ts new file mode 100644 index 000000000..61f9fc6b7 --- /dev/null +++ b/src/funcCoreTools/funcHostErrorUtils.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +import { type IRunningFuncTask, type IStoppedFuncTask } from './funcHostTask'; + +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 | IStoppedFuncTask | 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]`. + * 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(); +} + +// 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 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 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 b9987b511..a0d7b7984 100644 --- a/src/funcCoreTools/funcHostTask.ts +++ b/src/funcCoreTools/funcHostTask.ts @@ -13,11 +13,46 @@ import { getLocalSettingsJson } from '../funcConfig/local.settings'; import { localize } from '../localize'; import { cpUtils } from '../utils/cpUtils'; import { getWorkspaceSetting } from '../vsCodeConfig/settings'; +import { addErrorLinesFromChunk } from './funcHostErrorUtils'; export interface IRunningFuncTask { taskExecution: vscode.TaskExecution; processId: number; portNumber: string; + startTime: Date; + // 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; +} + +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; } interface DotnetDebugDebugConfiguration extends vscode.DebugConfiguration { @@ -44,9 +79,22 @@ 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); - buildPath = buildPath?.replace('${workspaceFolder}', (t.taskExecution.task?.scope as vscode.WorkspaceFolder).uri?.path); - return taskDirectory && buildPath && normalizePath(taskDirectory) === normalizePath(buildPath); + 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) { + return true; + } + + return taskDirectory && resolvedBuildPath && normalizePath(taskDirectory) === normalizePath(resolvedBuildPath); }); } @@ -76,34 +124,173 @@ class RunningFunctionTaskMap { export const runningFuncTaskMap: RunningFunctionTaskMap = new RunningFunctionTaskMap(); -const funcTaskStartedEmitter = new vscode.EventEmitter(); +/** + * 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; +const runningFuncTasksChangedEmitter = new vscode.EventEmitter(); +export const onRunningFuncTasksChanged = runningFuncTasksChangedEmitter.event; + +export function disposeFuncHostTaskEmitters(): void { + funcTaskStartedEmitter.dispose(); + runningFuncTasksChangedEmitter.dispose(); +} + 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 || ''); + 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 { + const commandLine = event.execution && event.execution.commandLine; + return funcCommandRegex.test(commandLine.value || ''); +} + + +let latestTerminalShellExecutionEvent: vscode.TerminalShellExecutionStartEvent | undefined; +export let terminalEventReader: vscode.Disposable; export function registerFuncHostTaskEvents(): void { + // we need to register this listener before the func host task starts, so we can capture the terminal output stream + terminalEventReader = vscode.window.onDidStartTerminalShellExecution(async (terminalShellExecEvent) => { + /** + * This will pick up any terminal that, 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; + }); 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, + startTime: new Date(), + 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(); } }); - 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 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(); + } + + // 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(); + } + }); + + 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); + } + + // 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) { + // 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); } }); @@ -129,7 +316,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); - } }); } @@ -145,7 +331,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 { @@ -162,6 +348,8 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol if (killAll) { runningFuncTaskMap.delete(workspaceFolder); } + + runningFuncTasksChangedEmitter.fire(); } /** diff --git a/src/tree/localProject/LocalProjectTreeItem.ts b/src/tree/localProject/LocalProjectTreeItem.ts index 677eb1793..81f36f64e 100644 --- a/src/tree/localProject/LocalProjectTreeItem.ts +++ b/src/tree/localProject/LocalProjectTreeItem.ts @@ -58,8 +58,10 @@ 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))); + // 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); this._localSettingsTreeItem = new AppSettingsTreeItem(this, new LocalSettingsClientProvider(this.workspaceFolder), ext.prefix, { @@ -123,9 +125,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/test/ansiUtils.test.ts b/test/ansiUtils.test.ts new file mode 100644 index 000000000..a4262c037 --- /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 '../src/utils/ansiUtils'; + +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..6483e29b9 --- /dev/null +++ b/test/funcHostErrorContext.test.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { addErrorLinesFromChunk, isFuncHostErrorLog } from '../src/funcCoreTools/funcHostErrorUtils'; + + +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('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}`); + }); + + 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); + }); +});