Skip to content

Commit a331ddc

Browse files
author
Nathan Turinski
committed
Fix duplicate log entries, fix func start tasks with args not being recognized
1 parent 2b81dd2 commit a331ddc

File tree

2 files changed

+107
-39
lines changed

2 files changed

+107
-39
lines changed

src/funcCoreTools/funcHostErrorUtils.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@
55

66
import { stripAnsiControlCharacters } from '../utils/ansiUtils';
77

8+
/**
9+
* Regex that matches a Functions-host timestamp prefix, e.g. `[2026-03-11T19:57:44.622Z]`.
10+
* Used to split raw terminal chunks into logical log entries.
11+
*/
12+
const funcHostTimestampRegex = /(?=\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z\])/;
13+
14+
/**
15+
* Collapse all whitespace runs to a single space for dedup comparison.
16+
* Terminal reflow wraps the same logical message at different column positions
17+
* depending on window width, producing different newline/space patterns for
18+
* an otherwise identical log entry.
19+
*/
20+
function normalizeWhitespace(text: string): string {
21+
return text.replace(/\s+/g, ' ').trim();
22+
}
23+
824
export interface FuncHostErrorContextOptions {
925
/**
1026
* Number of log lines to include before an error line
@@ -20,11 +36,64 @@ export interface FuncHostErrorContextOptions {
2036
max?: number;
2137
}
2238

39+
// Detect red/bright-red foreground in any of the common SGR forms:
40+
// Basic 4-bit: \x1b[31m (red) / \x1b[91m (bright red), with optional extra params
41+
// 256-color: \x1b[38;5;1m (red) / \x1b[38;5;9m (bright red)
42+
// 24-bit RGB: \x1b[38;2;R;G;Bm where R is dominant (R≥128, G≤64, B≤64)
43+
// eslint-disable-next-line no-control-regex
44+
const basicRedRegex = /\u001b\[(?:(?:\d+;)*(?:31|91)(?:;\d+)*m)/;
2345
// eslint-disable-next-line no-control-regex
24-
const redAnsiRegex = /\u001b\[(?:[0-9;]*31m|[0-9;]*91m)/;
46+
const extended256RedRegex = /\u001b\[38;5;(?:1|9)m/;
47+
48+
function isRedAnsi(text: string): boolean {
49+
return basicRedRegex.test(text) || extended256RedRegex.test(text);
50+
}
2551

2652
export function isFuncHostErrorLog(log: string): boolean {
27-
return redAnsiRegex.test(log);
53+
return isRedAnsi(log);
54+
}
55+
56+
/**
57+
* Splits a raw terminal chunk into logical log entries (by timestamp boundaries),
58+
* checks each entry for red ANSI codes, and appends only genuinely new error
59+
* entries to the given errorLogs array.
60+
*
61+
* @param errorLogs The mutable array of plain-text error strings to append to.
62+
* @param rawChunk A raw terminal output chunk (may contain ANSI + multiple log entries).
63+
* @returns `true` if at least one new error entry was added.
64+
*/
65+
export function addErrorLinesFromChunk(errorLogs: string[], rawChunk: string): boolean {
66+
const seen = new Set(errorLogs.map(normalizeWhitespace));
67+
let added = false;
68+
69+
// Split on timestamp boundaries so each segment is a complete log entry.
70+
for (const segment of rawChunk.split(funcHostTimestampRegex)) {
71+
if (!isFuncHostErrorLog(segment)) {
72+
continue;
73+
}
74+
75+
const plain = stripAnsiControlCharacters(segment).trim();
76+
if (!plain) {
77+
continue;
78+
}
79+
80+
const normalized = normalizeWhitespace(plain);
81+
if (seen.has(normalized)) {
82+
continue;
83+
}
84+
85+
seen.add(normalized);
86+
errorLogs.push(plain);
87+
added = true;
88+
}
89+
90+
// Keep the most recent few to avoid unbounded memory usage.
91+
const maxErrors = 10;
92+
if (errorLogs.length > maxErrors) {
93+
errorLogs.splice(0, errorLogs.length - maxErrors);
94+
}
95+
96+
return added;
2897
}
2998

3099
/**

src/funcCoreTools/funcHostTask.ts

Lines changed: 36 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@ import { tryGetFunctionProjectRoot } from '../commands/createNewProject/verifyIs
1111
import { localSettingsFileName } from '../constants';
1212
import { getLocalSettingsJson } from '../funcConfig/local.settings';
1313
import { localize } from '../localize';
14-
import { stripAnsiControlCharacters } from '../utils/ansiUtils';
1514
import { cpUtils } from '../utils/cpUtils';
1615
import { getWorkspaceSetting } from '../vsCodeConfig/settings';
17-
import { isFuncHostErrorLog } from './funcHostErrorUtils';
16+
import { addErrorLinesFromChunk } from './funcHostErrorUtils';
1817

1918
export interface IRunningFuncTask {
2019
taskExecution: vscode.TaskExecution;
@@ -40,26 +39,6 @@ export interface IRunningFuncTask {
4039
streamAbortController?: AbortController;
4140
}
4241

43-
function addErrorLog(task: IRunningFuncTask, rawChunk: string): void {
44-
const plain = stripAnsiControlCharacters(rawChunk).trim();
45-
if (!plain) {
46-
return;
47-
}
48-
49-
const arr = task.errorLogs ?? (task.errorLogs = []);
50-
if (arr[arr.length - 1] === plain) {
51-
return;
52-
}
53-
54-
arr.push(plain);
55-
56-
// Keep the most recent few to avoid unbounded memory usage.
57-
const maxErrors = 10;
58-
if (arr.length > maxErrors) {
59-
task.errorLogs = arr.slice(arr.length - maxErrors);
60-
}
61-
}
62-
6342
export interface IRunningFuncTaskWithScope {
6443
scope: vscode.WorkspaceFolder | vscode.TaskScope;
6544
task: IRunningFuncTask;
@@ -90,8 +69,14 @@ class RunningFunctionTaskMap {
9069
const taskExecution = t.taskExecution.task.execution as vscode.ShellExecution;
9170
// the cwd will include ${workspaceFolder} from our tasks.json so we need to replace it with the actual path
9271
const taskDirectory = taskExecution.options?.cwd?.replace('${workspaceFolder}', (t.taskExecution.task?.scope as vscode.WorkspaceFolder).uri?.path);
93-
buildPath = buildPath?.replace('${workspaceFolder}', (t.taskExecution.task?.scope as vscode.WorkspaceFolder).uri?.path);
94-
return taskDirectory && buildPath && normalizePath(taskDirectory) === normalizePath(buildPath);
72+
const resolvedBuildPath = buildPath?.replace('${workspaceFolder}', (t.taskExecution.task?.scope as vscode.WorkspaceFolder).uri?.path);
73+
74+
// When neither cwd is set, both tasks use the default working directory — treat as a match
75+
if (!taskDirectory && !resolvedBuildPath) {
76+
return true;
77+
}
78+
79+
return taskDirectory && resolvedBuildPath && normalizePath(taskDirectory) === normalizePath(resolvedBuildPath);
9580
});
9681
}
9782

@@ -157,8 +142,25 @@ const defaultFuncPort: string = '7071';
157142

158143
const funcCommandRegex: RegExp = /(func(?:\.exe)?)\s+host\s+start/i;
159144
export function isFuncHostTask(task: vscode.Task): boolean {
160-
const commandLine: string | undefined = task.execution && (<vscode.ShellExecution>task.execution).commandLine;
161-
return funcCommandRegex.test(commandLine || '');
145+
const execution = task.execution as vscode.ShellExecution | undefined;
146+
if (!execution) {
147+
return false;
148+
}
149+
150+
// String-based ShellExecution: `commandLine` contains the full command
151+
if (execution.commandLine) {
152+
return funcCommandRegex.test(execution.commandLine);
153+
}
154+
155+
// Args-based ShellExecution: `command` + `args` are separate
156+
// Reconstruct the command string to test against the regex
157+
const command = typeof execution.command === 'string' ? execution.command : execution.command?.value;
158+
if (command && execution.args) {
159+
const argsStr = execution.args.map(a => typeof a === 'string' ? a : a.value).join(' ');
160+
return funcCommandRegex.test(`${command} ${argsStr}`);
161+
}
162+
163+
return false;
162164
}
163165

164166
export function isFuncShellEvent(event: vscode.TerminalShellExecutionStartEvent): boolean {
@@ -219,12 +221,12 @@ export function registerFuncHostTaskEvents(): void {
219221
context.telemetry.suppressIfSuccessful = true;
220222
if (e.execution.task.scope !== undefined && isFuncHostTask(e.execution.task)) {
221223
const task = runningFuncTaskMap.get(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd);
222-
224+
223225
// Abort the stream iteration to prevent it from hanging indefinitely
224226
if (task?.streamAbortController) {
225227
task.streamAbortController.abort();
226228
}
227-
229+
228230
runningFuncTaskMap.delete(e.execution.task.scope, (e.execution.task.execution as vscode.ShellExecution).options?.cwd);
229231

230232
runningFuncTasksChangedEmitter.fire();
@@ -260,14 +262,11 @@ export function registerFuncHostTaskEvents(): void {
260262
task.logs.splice(0, task.logs.length - maxLogEntries);
261263
}
262264

263-
// Keep track of errors for the Debug view.
264-
if (isFuncHostErrorLog(chunk)) {
265-
const beforeCount = task.errorLogs?.length ?? 0;
266-
addErrorLog(task, chunk);
267-
const afterCount = task.errorLogs?.length ?? 0;
268-
if (afterCount > beforeCount) {
269-
runningFuncTasksChangedEmitter.fire();
270-
}
265+
// Split chunk into log entries by timestamp, check each for red
266+
// ANSI, and deduplicate against existing errors.
267+
const errorArr = task.errorLogs ?? (task.errorLogs = []);
268+
if (addErrorLinesFromChunk(errorArr, chunk)) {
269+
runningFuncTasksChangedEmitter.fire();
271270
}
272271
}
273272
} catch (error) {
@@ -319,7 +318,7 @@ export async function stopFuncTaskIfRunning(workspaceFolder: vscode.WorkspaceFol
319318

320319
if (runningFuncTask !== undefined && runningFuncTask.length > 0) {
321320
for (const runningFuncTaskItem of runningFuncTask) {
322-
if (!runningFuncTaskItem) {break;}
321+
if (!runningFuncTaskItem) { break; }
323322
if (terminate) {
324323
runningFuncTaskItem.taskExecution.terminate();
325324
} else {

0 commit comments

Comments
 (0)