Skip to content

Commit c594d4a

Browse files
authored
feat: add --detect-async-leaks (#9528)
1 parent b779bca commit c594d4a

File tree

20 files changed

+585
-4
lines changed

20 files changed

+585
-4
lines changed

docs/config/detectasyncleaks.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
title: detectAsyncLeaks | Config
3+
outline: deep
4+
---
5+
6+
# detectAsyncLeaks
7+
8+
- **Type:** `boolean`
9+
- **CLI:** `--detectAsyncLeaks`, `--detect-async-leaks`
10+
- **Default:** `false`
11+
12+
::: warning
13+
Enabling this option will make your tests run much slower. Use only when debugging or developing tests.
14+
:::
15+
16+
Detect asynchronous resources leaking from the test file.
17+
Uses [`node:async_hooks`](https://nodejs.org/api/async_hooks.html) to track creation of async resources. If a resource is not cleaned up, it will be logged after tests have finished.
18+
19+
For example if your code has `setTimeout` calls that execute the callback after tests have finished, you will see following error:
20+
21+
```sh
22+
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Async Leaks 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
23+
24+
Timeout leaking in test/checkout-screen.test.tsx
25+
26|
26+
27| useEffect(() => {
27+
28| setTimeout(() => setWindowWidth(window.innerWidth), 150)
28+
| ^
29+
29| })
30+
30|
31+
```
32+
33+
To fix this, you'll need to make sure your code cleans the timeout properly:
34+
35+
```js
36+
useEffect(() => {
37+
setTimeout(() => setWindowWidth(window.innerWidth), 150) // [!code --]
38+
const timeout = setTimeout(() => setWindowWidth(window.innerWidth), 150) // [!code ++]
39+
40+
return function cleanup() { // [!code ++]
41+
clearTimeout(timeout) // [!code ++]
42+
} // [!code ++]
43+
})
44+
```

docs/guide/cli-generated.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,13 @@ Pass when no tests are found
464464

465465
Show the size of heap for each test when running in node
466466

467+
### detectAsyncLeaks
468+
469+
- **CLI:** `--detectAsyncLeaks`
470+
- **Config:** [detectAsyncLeaks](/config/detectasyncleaks)
471+
472+
Detect asynchronous resources leaking from the test file (default: `false`)
473+
467474
### allowOnly
468475

469476
- **CLI:** `--allowOnly`

packages/vitest/src/defaults.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export const configDefaults: Readonly<{
8989
}
9090
slowTestThreshold: number
9191
disableConsoleIntercept: boolean
92+
detectAsyncLeaks: boolean
9293
}> = Object.freeze({
9394
allowOnly: !isCI,
9495
isolate: true,
@@ -126,4 +127,5 @@ export const configDefaults: Readonly<{
126127
},
127128
slowTestThreshold: 300,
128129
disableConsoleIntercept: false,
130+
detectAsyncLeaks: false,
129131
})

packages/vitest/src/node/cli/cli-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
441441
logHeapUsage: {
442442
description: 'Show the size of heap for each test when running in node',
443443
},
444+
detectAsyncLeaks: {
445+
description: 'Detect asynchronous resources leaking from the test file (default: `false`)',
446+
},
444447
allowOnly: {
445448
description:
446449
'Allow tests and suites that are marked as only (default: `!process.env.CI`)',

packages/vitest/src/node/config/resolveConfig.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,10 @@ export function resolveConfig(
354354
throw new Error(`"Istanbul" coverage provider is not compatible with "experimental.viteModuleRunner: false". Please, enable "viteModuleRunner" or switch to "v8" coverage provider.`)
355355
}
356356

357+
if (browser.enabled && resolved.detectAsyncLeaks) {
358+
logger.console.warn(c.yellow('The option "detectAsyncLeaks" is not supported in browser mode and will be ignored.'))
359+
}
360+
357361
const containsChromium = hasBrowserChromium(vitest, resolved)
358362
const hasOnlyChromium = hasOnlyBrowserChromium(vitest, resolved)
359363

packages/vitest/src/node/config/serializeConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export function serializeConfig(project: TestProject): SerializedConfig {
9696
inspect: globalConfig.inspect,
9797
inspectBrk: globalConfig.inspectBrk,
9898
inspector: globalConfig.inspector,
99+
detectAsyncLeaks: globalConfig.detectAsyncLeaks,
99100
watch: config.watch,
100101
includeTaskLocation:
101102
config.includeTaskLocation

packages/vitest/src/node/pools/rpc.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp
143143
onUnhandledError(err, type) {
144144
vitest.state.catchError(err, type)
145145
},
146+
onAsyncLeaks(leaks) {
147+
vitest.state.catchLeaks(leaks)
148+
},
146149
onCancel(reason) {
147150
vitest.cancelCurrentRun(reason)
148151
},

packages/vitest/src/node/printError.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,14 +389,14 @@ function printErrorMessage(error: TestError, logger: ErrorLogger) {
389389
}
390390
}
391391

392-
function printStack(
392+
export function printStack(
393393
logger: ErrorLogger,
394394
project: TestProject,
395395
stack: ParsedStack[],
396396
highlight: ParsedStack | undefined,
397397
errorProperties: Record<string, unknown>,
398398
onStack?: (stack: ParsedStack) => void,
399-
) {
399+
): void {
400400
for (const frame of stack) {
401401
const color = frame === highlight ? c.cyan : c.gray
402402
const path = relative(project.config.root, frame.file)

packages/vitest/src/node/projects/resolveProjects.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export async function resolveProjects(
4141
// not all options are allowed to be overridden
4242
const overridesOptions = [
4343
'logHeapUsage',
44+
'detectAsyncLeaks',
4445
'allowOnly',
4546
'sequence',
4647
'testTimeout',

packages/vitest/src/node/reporters/base.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Vitest } from '../core'
55
import type { TestSpecification } from '../test-specification'
66
import type { Reporter, TestRunEndReason } from '../types/reporter'
77
import type { TestCase, TestCollection, TestModule, TestModuleState, TestResult, TestSuite, TestSuiteState } from './reported-tasks'
8+
import { readFileSync } from 'node:fs'
89
import { performance } from 'node:perf_hooks'
910
import { getSuites, getTestName, getTests, hasFailed } from '@vitest/runner/utils'
1011
import { toArray } from '@vitest/utils/helpers'
@@ -14,6 +15,7 @@ import c from 'tinyrainbow'
1415
import { groupBy } from '../../utils/base'
1516
import { isTTY } from '../../utils/env'
1617
import { hasFailedSnapshot } from '../../utils/tasks'
18+
import { generateCodeFrame, printStack } from '../printError'
1719
import { F_CHECK, F_DOWN_RIGHT, F_POINTER } from './renderers/figures'
1820
import {
1921
countTestErrors,
@@ -519,6 +521,7 @@ export abstract class BaseReporter implements Reporter {
519521

520522
reportSummary(files: File[], errors: unknown[]): void {
521523
this.printErrorsSummary(files, errors)
524+
this.printLeaksSummary()
522525

523526
if (this.ctx.config.mode === 'benchmark') {
524527
this.reportBenchmarkSummary(files)
@@ -572,6 +575,12 @@ export abstract class BaseReporter implements Reporter {
572575
)
573576
}
574577

578+
const leaks = this.ctx.state.leakSet.size
579+
580+
if (leaks) {
581+
this.log(padSummaryTitle('Leaks'), c.bold(c.red(`${leaks} leak${leaks > 1 ? 's' : ''}`)))
582+
}
583+
575584
this.log(padSummaryTitle('Start at'), this._timeStart)
576585

577586
const collectTime = sum(files, file => file.collectDuration)
@@ -776,6 +785,51 @@ export abstract class BaseReporter implements Reporter {
776785
}
777786
}
778787

788+
private printLeaksSummary() {
789+
const leaks = this.ctx.state.leakSet
790+
791+
if (leaks.size === 0) {
792+
return
793+
}
794+
795+
this.error(`\n${errorBanner(`Async Leaks ${leaks.size}`)}\n`)
796+
797+
for (const leak of leaks) {
798+
const filename = this.relative(leak.filename)
799+
800+
this.ctx.logger.error(c.red(`${leak.type} leaking in ${filename}`))
801+
802+
const stacks = parseStacktrace(leak.stack)
803+
804+
if (stacks.length === 0) {
805+
continue
806+
}
807+
808+
try {
809+
const sourceCode = readFileSync(stacks[0].file, 'utf-8')
810+
811+
this.ctx.logger.error(generateCodeFrame(
812+
sourceCode.length > 100_000
813+
? sourceCode
814+
: this.ctx.logger.highlight(stacks[0].file, sourceCode),
815+
undefined,
816+
stacks[0],
817+
))
818+
}
819+
catch {
820+
// ignore error, do not produce more detailed message with code frame.
821+
}
822+
823+
printStack(
824+
this.ctx.logger,
825+
this.ctx.getProjectByName(leak.projectName),
826+
stacks,
827+
stacks[0],
828+
{},
829+
)
830+
}
831+
}
832+
779833
reportBenchmarkSummary(files: File[]): void {
780834
const benches = getTests(files)
781835
const topBenches = benches.filter(i => i.result?.benchmark?.rank === 1)

0 commit comments

Comments
 (0)