Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,12 @@
"properties": {
"command": {
"type": "string"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
Expand Down Expand Up @@ -1504,5 +1510,8 @@
"runWizardCommandWithoutExecutionCommandId": "azureFunctions.agent.runWizardCommandWithoutExecution",
"runWizardCommandWithInputsCommandId": "azureFunctions.agent.runWizardCommandWithInputs",
"getAgentBenchmarkConfigsCommandId": "azureFunctions.agent.getAgentBenchmarkConfigs"
}
},
"enabledApiProposals": [
"terminalDataWriteEvent"
]
}
86 changes: 65 additions & 21 deletions src/commands/pickFuncProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,17 @@ export async function startFuncProcessFromApi(
buildPath: string,
args: string[],
env: { [key: string]: string }
): Promise<{ processId: string; success: boolean; error: string }> {
const result = {
): Promise<{ processId: string; success: boolean; error: string, stream: AsyncIterable<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 +72,7 @@ export async function startFuncProcessFromApi(
const taskInfo = await startFuncTask(context, workspaceFolder, buildPath, funcTask);
result.processId = await pickChildProcess(taskInfo);
result.success = true;
result.stream = taskInfo.streamHandler.stream;
} catch (err) {
const pError = parseError(err);
result.error = pError.message;
Expand Down Expand Up @@ -140,34 +147,54 @@ async function startFuncTask(context: IActionContext, workspaceFolder: vscode.Wo
const funcPort: string = await getFuncPortFromTaskOrProject(context, funcTask, workspaceFolder);
let statusRequestTimeout: number = intervalMs;
const maxTime: number = Date.now() + timeoutInSeconds * 1000;
const debugModeOn = funcTask.name.includes('--dotnet-isolated-debug') && funcTask.name.includes('--enable-json-output');
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

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

Checking for command-line flags in the task name is fragile and unclear. Task names are meant for display purposes. Consider checking the actual command line or task definition properties instead.

Copilot uses AI. Check for mistakes.
let eventDisposable: vscode.Disposable | undefined;
let parentPid: number | undefined;

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 (!eventDisposable) {
// preserve the old pid to detect changes
parentPid = taskInfo.processId;
eventDisposable = await setEventPidByJsonOutput(taskInfo, funcTask.name);
}

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

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

async function setEventPidByJsonOutput(taskInfo: IRunningFuncTask, taskName: string): Promise<vscode.Disposable> {
const setPidByJsonOutputListener = vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => {
const terminal = vscode.window.terminals.find(t => taskName === t.name);
if (event.terminal === terminal) {
if (event.data.includes(`{ "name":"dotnet-worker-startup", "workerProcessId" :`)) {
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

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

Using a hardcoded JSON fragment string for matching is fragile. JSON can have varying whitespace. Consider parsing the output as JSON or using a more robust pattern match that accounts for whitespace variations.

Copilot uses AI. Check for mistakes.
const matches = event.data.match(/"workerProcessId"\s*:\s*(\d+)/);
if (matches && matches.length > 1) {
taskInfo.processId = Number(matches[1]);
setPidByJsonOutputListener.dispose();
}
}
}
});
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

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

The listener mutates taskInfo.processId without synchronization. If terminal data arrives concurrently while the main loop in startFuncTask checks taskInfo.processId !== parentPid, there's a potential race condition where the PID could be updated between the check and the return statement.

Copilot uses AI. Check for mistakes.

return setPidByJsonOutputListener;
}

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) {
command = `${command} ${args.join(' ')}`;
}

let commandLine: string = `${funcCliPath} ${command}`;
if (language === ProjectLanguage.Python) {
commandLine = venvUtils.convertToVenvCommand(commandLine, folder.uri.fsPath);
Expand Down
18 changes: 16 additions & 2 deletions src/funcCoreTools/funcHostTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ import { localSettingsFileName } from '../constants';
import { getLocalSettingsJson } from '../funcConfig/local.settings';
import { localize } from '../localize';
import { cpUtils } from '../utils/cpUtils';
import { createAsyncStringStream, type AsyncStreamHandler } from '../utils/stream';
import { getWorkspaceSetting } from '../vsCodeConfig/settings';

export interface IRunningFuncTask {
taskExecution: vscode.TaskExecution;
processId: number;
portNumber: string;
streamHandler: AsyncStreamHandler;
outputReader: vscode.Disposable;
}

interface DotnetDebugDebugConfiguration extends vscode.DebugConfiguration {
Expand Down Expand Up @@ -92,7 +95,16 @@ export function registerFuncHostTaskEvents(): void {
context.telemetry.suppressIfSuccessful = true;
if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) {
const portNumber = await getFuncPortFromTaskOrProject(context, e.execution.task, e.execution.task.scope);
const runningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber };
const streamHandler = createAsyncStringStream();
const terminalName = e.execution.task.name;
const outputReader = vscode.window.onDidWriteTerminalData(async (event: vscode.TerminalDataWriteEvent) => {
const terminal = vscode.window.terminals.find(t => terminalName === t.name);
if (event.terminal === terminal) {
runningFuncTask.streamHandler.write(event.data);
}
});
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

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

Variable runningFuncTask is referenced before it's declared on line 107. This will cause a ReferenceError at runtime. Move the declaration of runningFuncTask before the outputReader initialization.

Copilot uses AI. Check for mistakes.

const runningFuncTask = { processId: e.processId, taskExecution: e.execution, portNumber, streamHandler, outputReader };
runningFuncTaskMap.set(e.execution.task.scope, runningFuncTask);
funcTaskStartedEmitter.fire(e.execution.task.scope);
}
Expand Down Expand Up @@ -146,10 +158,12 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol
for (const runningFuncTaskItem of runningFuncTask) {
if (!runningFuncTaskItem) break;
if (terminate) {
runningFuncTaskItem.taskExecution.terminate()
runningFuncTaskItem.taskExecution.terminate();
} else {
// Try to find the real func process by port first, fall back to shell PID
await killFuncProcessByPortOrPid(runningFuncTaskItem, workspaceFolder);
runningFuncTaskItem.streamHandler.end();
runningFuncTaskItem.outputReader.dispose();
}
}

Expand Down
61 changes: 61 additions & 0 deletions src/utils/stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

export type AsyncStreamHandler = {
stream: AsyncIterable<string>;
write: (chunk: string) => void;
end: () => void;
};

export function createAsyncStringStream(): AsyncStreamHandler {
const queue: (string | null)[] = [];
let resolveNext: ((result: IteratorResult<string>) => void) | null = null;
let done = false;

const stream: AsyncIterable<string> = {
[Symbol.asyncIterator](): AsyncIterator<string> {
return {
next() {
return new Promise<IteratorResult<string>>(resolve => {
if (queue.length > 0) {
const value = queue.shift();
if (value === null) {
resolve({ value: undefined, done: true });
} else {
resolve({ value: value as string, done: false });
}
} else if (done) {
resolve({ value: undefined, done: true });
} else {
resolveNext = resolve;
}
});
}
};
}
};

function write(chunk: string) {
if (done) throw new Error("Cannot write to an ended stream");
if (resolveNext) {
resolveNext({ value: chunk, done: false });
resolveNext = null;
} else {
queue.push(chunk);
}
}

function end() {
done = true;
if (resolveNext) {
resolveNext({ value: undefined, done: true });
resolveNext = null;
} else {
queue.push(null); // sentinel for end
}
}

return { stream, write, end };
}
29 changes: 29 additions & 0 deletions vscode.proposed.terminalDataWriteEvent.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

declare module 'vscode' {

// https://github.com/microsoft/vscode/issues/78502

export interface TerminalDataWriteEvent {
/**
* The {@link Terminal} for which the data was written.
*/
readonly terminal: Terminal;
/**
* The data being written.
*/
readonly data: string;
}

namespace window {
/**
* An event which fires when the terminal's child pseudo-device is written to (the shell).
* In other words, this provides access to the raw data stream from the process running
* within the terminal, including VT sequences.
*/
export const onDidWriteTerminalData: Event<TerminalDataWriteEvent>;
}
}