Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6e5b61c
Implementation for debug-isolated flag and streaming func CLI output
nturinski Oct 24, 2025
e9c255d
Update src/utils/stream.ts
nturinski Oct 24, 2025
067e04d
Address copilot feedback
nturinski Oct 24, 2025
d046ac9
Merge branch 'nat/fileBasedPickProcess' of https://github.com/microso…
nturinski Oct 24, 2025
606f130
Refactor to set up stream in funcHostTasks
nturinski Oct 24, 2025
44e8382
Delete test snippet
nturinski Oct 24, 2025
9726543
Add headerto stream file
nturinski Oct 24, 2025
d4f3b56
Remove the done call
nturinski Oct 24, 2025
c7618c7
Update src/debug/FuncTaskProvider.ts
nturinski Oct 24, 2025
b8dd0bf
WIP waiting for VSCode insiders fix
nturinski Oct 29, 2025
d673a73
Use onDidStartTerminalShellExecution API instead of proposed
nturinski Oct 30, 2025
2dfd68d
Remove proposed files
nturinski Oct 30, 2025
5035c9a
Merge branch 'nat/fileBasedPickProcess' of https://github.com/microso…
nturinski Oct 30, 2025
8a751c8
Add note
nturinski Oct 30, 2025
e5adbb9
Remove unused async moniker
nturinski Oct 30, 2025
6454ca8
Whoops, wrong async
nturinski Oct 30, 2025
544cd45
Little bit of cleaning
nturinski Oct 30, 2025
52780f7
Remove unusued function helper
nturinski Oct 30, 2025
3dc0fc5
Last commit, I swears it
nturinski Oct 30, 2025
e36604c
Move event handler, added note
nturinski Nov 3, 2025
5bfbb42
Rename function due to PR feedback
nturinski Nov 3, 2025
a9c1f08
Merge branch 'main' of https://github.com/microsoft/vscode-azurefunct…
nturinski Nov 19, 2025
bc365fa
Push up WIP
nturinski Nov 21, 2025
5d5b38f
Push up WIP
nturinski Nov 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,12 @@
"properties": {
"command": {
"type": "string"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
Expand Down
48 changes: 48 additions & 0 deletions src/commands/debug/PostFuncDebugExecuteStep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { ActivityChildItem, ActivityChildType, activityInfoContext, AzureWizardExecuteStep, createContextValue, type ExecuteActivityOutput, type IActionContext } from "@microsoft/vscode-azext-utils";
import { localize } from "../../localize";

export class PostFuncDebugExecuteStep<T extends IActionContext> extends AzureWizardExecuteStep<T> {
public priority: number = 999;
public stepName: string = 'PostFuncDebugExecuteStep';

public async execute(_context: T): Promise<void> {
// no-op
}

public createSuccessOutput(context: T): ExecuteActivityOutput {
const terminateDebugSession: string = localize('connectMcpServer', 'Successfully terminated debug session.');
return {
item: new ActivityChildItem({
label: terminateDebugSession,
id: `${context.telemetry.properties.sessionId}-terminateDebugSession`,
activityType: ActivityChildType.Success,
contextValue: createContextValue([activityInfoContext, 'terminateDebugSession']),
// a little trick to remove the description timer on activity children
description: ' '
})
};
}

public createFailOutput(context: T): ExecuteActivityOutput {
const terminateDebugSession: string = localize('terminateDebugSessionFail', 'Failed to terminate debug session.');
return {
item: new ActivityChildItem({
label: terminateDebugSession,
id: `${context.telemetry.properties.sessionId}-terminateDebugSession-fail`,
activityType: ActivityChildType.Error,
contextValue: createContextValue([activityInfoContext, 'terminateDebugSessionFail']),
// a little trick to remove the description timer on activity children
description: ' '
})
};
}

public shouldExecute(context: T): boolean {
return true;
}
}
85 changes: 63 additions & 22 deletions src/commands/pickFuncProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,25 @@ import { getWorkspaceSetting } from '../vsCodeConfig/settings';

const funcTaskReadyEmitter = new vscode.EventEmitter<vscode.WorkspaceFolder>();
export const onDotnetFuncTaskReady = funcTaskReadyEmitter.event;
// flag used by func core tools to indicate to wait for the debugger to attach before starting the worker
const dotnetIsolatedDebugFlag = '--dotnet-isolated-debug';
const enableJsonOutput = '--enable-json-output';

export async function startFuncProcessFromApi(
buildPath: string,
args: string[],
env: { [key: string]: string }
): Promise<{ processId: string; success: boolean; error: string }> {
const result = {
): Promise<{ processId: string; success: boolean; error: string, stream: AsyncIterable<string> | undefined }> {
const result: {
processId: string;
success: boolean;
error: string;
stream: AsyncIterable<string> | undefined;
} = {
processId: '',
success: false,
error: ''
error: '',
stream: undefined
};

let funcHostStartCmd: string = 'func host start';
Expand Down Expand Up @@ -66,6 +75,7 @@ export async function startFuncProcessFromApi(
const taskInfo = await startFuncTask(context, workspaceFolder, buildPath, funcTask);
result.processId = await pickChildProcess(taskInfo);
result.success = true;
result.stream = taskInfo.stream;
} catch (err) {
const pError = parseError(err);
result.error = pError.message;
Expand Down Expand Up @@ -140,34 +150,48 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo
const funcPort: string = await getFuncPortFromTaskOrProject(context, funcTask, workspaceFolder);
let statusRequestTimeout: number = intervalMs;
const maxTime: number = Date.now() + timeoutInSeconds * 1000;
const funcShellExecution = funcTask.execution as vscode.ShellExecution;
const debugModeOn = funcShellExecution.commandLine?.includes(dotnetIsolatedDebugFlag) && funcShellExecution.commandLine?.includes(enableJsonOutput);

while (Date.now() < maxTime) {
if (taskError !== undefined) {
throw taskError;
}

const taskInfo: IRunningFuncTask | undefined = runningFuncTaskMap.get(workspaceFolder, buildPath);
if (taskInfo) {
for (const scheme of ['http', 'https']) {
const statusRequest: AzExtRequestPrepareOptions = { url: `${scheme}://localhost:${funcPort}/admin/host/status`, method: 'GET' };
if (scheme === 'https') {
statusRequest.rejectUnauthorized = false;
if (debugModeOn) {
// if we are in dotnet isolated debug mode, we need to find the pid from the terminal output
// if there is no pid yet, keep waiting
const newPid = await getWorkerPidFromJsonOutput(taskInfo);
if (newPid) {
taskInfo.processId = newPid;
return taskInfo;
}

try {
// wait for status url to indicate functions host is running
const response = await sendRequestWithTimeout(context, statusRequest, statusRequestTimeout, undefined);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
if (response.parsedBody.state.toLowerCase() === 'running') {
funcTaskReadyEmitter.fire(workspaceFolder);
return taskInfo;
} else {
// otherwise, we have to wait for the status url to indicate the host is running
for (const scheme of ['http', 'https']) {
const statusRequest: AzExtRequestPrepareOptions = { url: `${scheme}://localhost:${funcPort}/admin/host/status`, method: 'GET' };
if (scheme === 'https') {
statusRequest.rejectUnauthorized = false;
}
} catch (error) {
if (requestUtils.isTimeoutError(error)) {
// Timeout likely means localhost isn't ready yet, but we'll increase the timeout each time it fails just in case it's a slow computer that can't handle a request that fast
statusRequestTimeout *= 2;
context.telemetry.measurements.maxStatusTimeout = statusRequestTimeout;
} else {
// ignore

try {
// wait for status url to indicate functions host is running
const response = await sendRequestWithTimeout(context, statusRequest, statusRequestTimeout, undefined);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
if (response.parsedBody.state.toLowerCase() === 'running') {
funcTaskReadyEmitter.fire(workspaceFolder);
return taskInfo;
}
} catch (error) {
if (requestUtils.isTimeoutError(error)) {
// Timeout likely means localhost isn't ready yet, but we'll increase the timeout each time it fails just in case it's a slow computer that can't handle a request that fast
statusRequestTimeout *= 2;
context.telemetry.measurements.maxStatusTimeout = statusRequestTimeout;
} else {
// ignore
}
}
}
}
Expand All @@ -182,6 +206,23 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo
}
}

async function getWorkerPidFromJsonOutput(taskInfo: IRunningFuncTask): Promise<number | undefined> {
// if there is no stream yet or if the output doesn't include the workerProcessId yet, then keep waiting
if (!taskInfo.stream) {
return;
}

for await (const chunk of taskInfo.stream) {
if (chunk.includes(`{ "name":"dotnet-worker-startup", "workerProcessId" :`)) {
const matches = chunk.match(/"workerProcessId"\s*:\s*(\d+)/);
if (matches && matches.length > 1) {
return Number(matches[1]);
}
}
Comment on lines +216 to +221
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded JSON string fragment with specific formatting assumptions is brittle. JSON output may have varying whitespace, and this check will fail if the format changes slightly (e.g., no space after :). Consider parsing the chunk as JSON and checking the structure programmatically, or use a more flexible pattern that accommodates whitespace variations.

Suggested change
if (chunk.includes(`{ "name":"dotnet-worker-startup", "workerProcessId" :`)) {
const matches = chunk.match(/"workerProcessId"\s*:\s*(\d+)/);
if (matches && matches.length > 1) {
return Number(matches[1]);
}
}
let obj: unknown;
try {
obj = JSON.parse(chunk);
} catch {
continue; // Not valid JSON, skip this chunk
}
if (
typeof obj === 'object' &&
obj !== null &&
(obj as any).name === "dotnet-worker-startup" &&
typeof (obj as any).workerProcessId === "number"
) {
return (obj as any).workerProcessId;
}

Copilot uses AI. Check for mistakes.
}
return;
Comment on lines +215 to +223
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The for await loop will consume the entire stream and never exit if the expected JSON output is not found, causing the function to hang indefinitely. The stream reading should have a timeout or break condition, and the loop at line 156 in startFuncTask should handle this case to avoid waiting forever.

Suggested change
for await (const chunk of taskInfo.stream) {
if (chunk.includes(`{ "name":"dotnet-worker-startup", "workerProcessId" :`)) {
const matches = chunk.match(/"workerProcessId"\s*:\s*(\d+)/);
if (matches && matches.length > 1) {
return Number(matches[1]);
}
}
}
return;
const TIMEOUT_MS = 10000; // 10 seconds
let timeoutHandle: NodeJS.Timeout;
return await Promise.race([
(async () => {
for await (const chunk of taskInfo.stream) {
if (chunk.includes(`{ "name":"dotnet-worker-startup", "workerProcessId" :`)) {
const matches = chunk.match(/"workerProcessId"\s*:\s*(\d+)/);
if (matches && matches.length > 1) {
clearTimeout(timeoutHandle);
return Number(matches[1]);
}
}
}
return undefined;
})(),
new Promise<number | undefined>(resolve => {
timeoutHandle = setTimeout(() => {
resolve(undefined);
}, TIMEOUT_MS);
})
]);

Copilot uses AI. Check for mistakes.
}

type OSAgnosticProcess = { command: string | undefined; pid: number | string };

/**
Expand Down
5 changes: 5 additions & 0 deletions src/debug/FuncTaskProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task> {
const funcCliPath = await getFuncCliPath(context, folder);
const args = (definition?.args || []) as string[];
if (args.length > 0) {
command = `${command} ${args.join(' ')}`;
}
Comment on lines +107 to +110
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Command arguments from task definitions are concatenated directly into shell commands without sanitization or escaping. If args contain shell metacharacters (e.g., ;, |, $(), backticks), this could lead to command injection. Consider using proper shell escaping or switching to an array-based execution approach where arguments are passed separately from the command.

Copilot uses AI. Check for mistakes.

let commandLine: string = `${funcCliPath} ${command}`;
if (language === ProjectLanguage.Python) {
commandLine = venvUtils.convertToVenvCommand(commandLine, folder.uri.fsPath);
Expand Down
3 changes: 2 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { PowerShellDebugProvider } from './debug/PowerShellDebugProvider';
import { PythonDebugProvider } from './debug/PythonDebugProvider';
import { handleUri } from './downloadAzureProject/handleUri';
import { ext } from './extensionVariables';
import { registerFuncHostTaskEvents } from './funcCoreTools/funcHostTask';
import { registerFuncHostTaskEvents, terminalEventReader } from './funcCoreTools/funcHostTask';
import { validateFuncCoreToolsInstalled } from './funcCoreTools/validateFuncCoreToolsInstalled';
import { validateFuncCoreToolsIsLatest } from './funcCoreTools/validateFuncCoreToolsIsLatest';
import { getResourceGroupsApi } from './getExtensionApi';
Expand Down Expand Up @@ -154,4 +154,5 @@ export async function activateInternal(context: vscode.ExtensionContext, perfSta

export async function deactivateInternal(): Promise<void> {
await emulatorClient.disposeAsync();
terminalEventReader?.dispose();
}
50 changes: 45 additions & 5 deletions src/funcCoreTools/funcHostTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { registerEvent, type IActionContext } from '@microsoft/vscode-azext-utils';
import { AzureWizard, registerEvent, type IActionContext } from '@microsoft/vscode-azext-utils';
import * as path from 'path';
import * as vscode from 'vscode';
import { tryGetFunctionProjectRoot } from '../commands/createNewProject/verifyIsProject';
import { PostFuncDebugExecuteStep } from '../commands/debug/PostFuncDebugExecuteStep';
import { localSettingsFileName } from '../constants';
import { getLocalSettingsJson } from '../funcConfig/local.settings';
import { localize } from '../localize';
import { createActivityContext } from '../utils/activityUtils';
import { cpUtils } from '../utils/cpUtils';
import { getWorkspaceSetting } from '../vsCodeConfig/settings';

export interface IRunningFuncTask {
taskExecution: vscode.TaskExecution;
processId: number;
portNumber: string;
// stream for reading `func host start` output
stream: AsyncIterable<string> | undefined;
}

interface DotnetDebugDebugConfiguration extends vscode.DebugConfiguration {
Expand Down Expand Up @@ -81,28 +85,64 @@ export const onFuncTaskStarted = funcTaskStartedEmitter.event;
export const buildPathToWorkspaceFolderMap = new Map<string, vscode.WorkspaceFolder>();
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 && (<vscode.ShellExecution>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) => {
/**
* This will pick up any terminal that starts a `func host start` command, including those started outside of tasks (e.g. via the command palette).
* But we don't actually access the terminal stream until the `func host start` task starts, at which time this will be pointing to the correct terminal
* */
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 runningFuncTask: IRunningFuncTask = {
processId: e.processId,
taskExecution: e.execution,
portNumber,
stream: latestTerminalShellExecutionEvent?.execution.read()
};

runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask);
funcTaskStartedEmitter.fire(e.execution.task.scope);
}
});

registerEvent('azureFunctions.onDidEndTask', vscode.tasks.onDidEndTaskProcess, (context: IActionContext, e: vscode.TaskProcessEndEvent) => {
registerEvent('azureFunctions.onDidEndTask', vscode.tasks.onDidEndTaskProcess, async (context: IActionContext, e: vscode.TaskProcessEndEvent) => {
context.errorHandling.suppressDisplay = true;
context.telemetry.suppressIfSuccessful = true;
if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) {
runngFuncTask.map.get
runningFuncTaskMap.delete(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd);
const wizardContext = Object.assign(context, await createActivityContext({ withChildren: true }));
const wizard = new AzureWizard(wizardContext, {
title: localize('funcTaskEnded', 'Function host task ended.'),
promptSteps: [],
executeSteps: [new PostFuncDebugExecuteStep()]
});
await wizard.execute();
}
});

Expand Down Expand Up @@ -146,7 +186,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);
Expand Down