Skip to content

Commit 6d5b5b1

Browse files
Copilotsheremet-vaAriPerkkio
authored
fix: resolve performance issue when throwing errors with stackTraceLimit = 0 (#8531)
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: sheremet-va <[email protected]> Co-authored-by: Ari Perkkiö <[email protected]>
1 parent 3be8698 commit 6d5b5b1

File tree

2 files changed

+115
-8
lines changed

2 files changed

+115
-8
lines changed

packages/utils/src/source-map.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,38 @@ export function parseSingleFFOrSafariStack(raw: string): ParsedStack | null {
9191
)
9292
}
9393

94-
if (!line.includes('@') && !line.includes(':')) {
94+
// Early return for lines that don't look like Firefox/Safari stack traces
95+
// Firefox/Safari stack traces must contain '@' and should have location info after it
96+
if (!line.includes('@')) {
9597
return null
9698
}
9799

98-
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/optimal-quantifier-concatenation
99-
const functionNameRegex = /((.*".+"[^@]*)?[^@]*)(@)/
100-
const matches = line.match(functionNameRegex)
101-
const functionName = matches && matches[1] ? matches[1] : undefined
102-
const [url, lineNumber, columnNumber] = extractLocation(
103-
line.replace(functionNameRegex, ''),
104-
)
100+
// Find the correct @ that separates function name from location
101+
// For cases like '@https://@fs/path' or 'functionName@https://@fs/path'
102+
// we need to find the first @ that precedes a valid location (containing :)
103+
let atIndex = -1
104+
let locationPart = ''
105+
let functionName: string | undefined
106+
107+
// Try each @ from left to right to find the one that gives us a valid location
108+
for (let i = 0; i < line.length; i++) {
109+
if (line[i] === '@') {
110+
const candidateLocation = line.slice(i + 1)
111+
// Minimum length 3 for valid location: 1 for filename + 1 for colon + 1 for line number (e.g., "a:1")
112+
if (candidateLocation.includes(':') && candidateLocation.length >= 3) {
113+
atIndex = i
114+
locationPart = candidateLocation
115+
functionName = i > 0 ? line.slice(0, i) : undefined
116+
break
117+
}
118+
}
119+
}
120+
121+
// Validate we found a valid location with minimum length (filename:line format)
122+
if (atIndex === -1 || !locationPart.includes(':') || locationPart.length < 3) {
123+
return null
124+
}
125+
const [url, lineNumber, columnNumber] = extractLocation(locationPart)
105126

106127
if (!url || !lineNumber || !columnNumber) {
107128
return null

test/core/test/utils.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { assertTypes, deepClone, deepMerge, isNegativeNaN, objDisplay, objectAttr, toArray } from '@vitest/utils'
2+
import { parseSingleFFOrSafariStack } from '@vitest/utils/source-map'
23
import { EvaluatedModules } from 'vite/module-runner'
34
import { beforeAll, describe, expect, test } from 'vitest'
45
import { deepMergeSnapshot } from '../../../packages/snapshot/src/port/utils'
@@ -306,3 +307,88 @@ describe('isNegativeNaN', () => {
306307
expect(isNegativeNaN(value)).toBe(expected)
307308
})
308309
})
310+
311+
describe('parseSingleFFOrSafariStack', () => {
312+
test('should parse valid Firefox/Safari stack traces with file protocol', () => {
313+
const validStackLine = 'functionName@file:///path/to/file.js:123:45'
314+
315+
const result = parseSingleFFOrSafariStack(validStackLine)
316+
317+
expect(result).toEqual({
318+
file: 'file:///path/to/file.js',
319+
method: 'functionName',
320+
line: 123,
321+
column: 45,
322+
})
323+
})
324+
325+
test('should parse valid Firefox/Safari stack traces with https protocol', () => {
326+
const validStackLine = 'functionName@https://example.com/path/to/file.js:123:45'
327+
328+
const result = parseSingleFFOrSafariStack(validStackLine)
329+
330+
expect(result).toEqual({
331+
file: '/path/to/file.js',
332+
method: 'functionName',
333+
line: 123,
334+
column: 45,
335+
})
336+
})
337+
338+
test('should handle stack lines without function names', () => {
339+
const stackLineWithoutFunction = '@file:///path/to/file.js:123:45'
340+
341+
const result = parseSingleFFOrSafariStack(stackLineWithoutFunction)
342+
343+
expect(result).toEqual({
344+
file: 'file:///path/to/file.js',
345+
method: '',
346+
line: 123,
347+
column: 45,
348+
})
349+
})
350+
351+
test('should parse https URLs with @fs prefix without function name', () => {
352+
const stackLine = '@https://@fs/path/to/file.js:123:4'
353+
354+
const result = parseSingleFFOrSafariStack(stackLine)
355+
356+
expect(result).toEqual({
357+
file: '/path/to/file.js',
358+
method: '',
359+
line: 123,
360+
column: 4,
361+
})
362+
})
363+
364+
test('should parse https URLs with @fs prefix with function name', () => {
365+
const stackLine = 'functionName@https://@fs/path/to/file.js:123:4'
366+
367+
const result = parseSingleFFOrSafariStack(stackLine)
368+
369+
expect(result).toEqual({
370+
file: '/path/to/file.js',
371+
method: 'functionName',
372+
line: 123,
373+
column: 4,
374+
})
375+
})
376+
377+
test('should not hang when `Error.stackTraceLimit = 0` (#6039)', { timeout: 5_000 }, async () => {
378+
// 40 takes ~30s on M2 CPU when fix is reverted
379+
const size = 40
380+
381+
const obj = Object.fromEntries(
382+
[...Array.from({ length: size }).keys()].map(i => [`prop${i}`, i]),
383+
)
384+
385+
class PrettyError extends globalThis.Error {
386+
constructor(e: unknown) {
387+
Error.stackTraceLimit = 0
388+
super(JSON.stringify(e))
389+
}
390+
}
391+
392+
parseSingleFFOrSafariStack(new PrettyError(obj).stack!)
393+
})
394+
})

0 commit comments

Comments
 (0)