Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(vitest): support --detectAsyncLeaks cli flag #5395

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/vitest/src/node/cli/cli-config.ts
Expand Up @@ -582,6 +582,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
clearScreen: {
description: 'Clear terminal screen when re-running tests during watch mode (default: true)',
},
detectAsyncLeaks: {
description: 'Detect async leaks in the test suite. This will slow down the test suite, use only in node environment (default: false)',
},

// disable CLI options
cliExclude: null,
Expand Down
11 changes: 11 additions & 0 deletions packages/vitest/src/node/core.ts
Expand Up @@ -16,6 +16,7 @@ import { getCoverageProvider } from '../integrations/coverage'
import { CONFIG_NAMES, configFiles, workspacesFiles as workspaceFiles } from '../constants'
import { rootDir } from '../paths'
import { WebSocketReporter } from '../api/setup'
import type { HangingOps } from '../runtime/runners/with-async-leaks-detecter'
import { createPool } from './pool'
import type { ProcessPool, WorkspaceSpec } from './pool'
import { createBenchmarkReporters, createReporters } from './reporters/utils'
Expand Down Expand Up @@ -69,6 +70,8 @@ export class Vitest {

public distPath!: string

public hangingOps?: HangingOps[]

constructor(
public readonly mode: VitestRunMode,
options: VitestOptions = {},
Expand Down Expand Up @@ -487,6 +490,8 @@ export class Vitest {

await this.report('onPathsCollected', filepaths)

const detectAsyncLeaks = this.config.detectAsyncLeaks && !this.config.browser.enabled

// previous run
await this.runningPromise
this._onCancelListeners = []
Expand All @@ -505,6 +510,9 @@ export class Vitest {
if (!this.isFirstRun && this.config.coverage.cleanOnRerun)
await this.coverageProvider?.clean()

if (detectAsyncLeaks)
this.hangingOps = []

await this.initializeGlobalSetup(paths)

try {
Expand All @@ -528,6 +536,9 @@ export class Vitest {
await this.report('onFinished', this.state.getFiles(specs), this.state.getUnhandledErrors())
await this.reportCoverage(allTestsRun)

if (detectAsyncLeaks)
await this.logger.printAsyncLeaksWarning(this.hangingOps!)

this.runningPromise = undefined
this.isFirstRun = false

Expand Down
21 changes: 20 additions & 1 deletion packages/vitest/src/node/logger.ts
Expand Up @@ -3,8 +3,9 @@ import c from 'picocolors'
import { version } from '../../../../package.json'
import type { ErrorWithDiff } from '../types'
import type { TypeCheckError } from '../typecheck/typechecker'
import { toArray } from '../utils'
import { getFullName, toArray } from '../utils'
import { highlightCode } from '../utils/colors'
import type { HangingOps } from '../runtime/runners/with-async-leaks-detecter'
import { divider } from './reporters/renderers/utils'
import { RandomSequencer } from './sequencers/RandomSequencer'
import type { Vitest } from './core'
Expand Down Expand Up @@ -212,4 +213,22 @@ export class Logger {
}))
this.log(c.red(divider()))
}

async printAsyncLeaksWarning(hangingOps: HangingOps[]) {
if (hangingOps.length === 0)
return
const errorMessage = c.yellow(c.bold(
`\nVitest has detected the following ${hangingOps.length} hanging operation${hangingOps.length > 1 ? 's' : ''} potentially keeping Vitest from exiting: \n`,
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
))
this.log(c.yellow(divider(c.bold(c.inverse(' Hanging Operations ')))))
this.log(errorMessage)

hangingOps.forEach(({ type, taskId, stack }) => {
const task = taskId && this.ctx.state.idMap.get(taskId)
this.log(type + c.dim(` | ${task ? getFullName(task) : taskId}`))
this.log(`${c.gray(c.dim(`${stack}`))}\n`)
})

this.log(c.yellow(divider()))
}
}
3 changes: 3 additions & 0 deletions packages/vitest/src/node/pools/rpc.ts
Expand Up @@ -60,5 +60,8 @@ export function createMethodsRPC(project: WorkspaceProject): RuntimeRPC {
getCountOfFailedTests() {
return ctx.state.getCountOfFailedTests()
},
detectAsyncLeaks(hangingOps) {
ctx.hangingOps?.push(...hangingOps)
},
}
}
1 change: 1 addition & 0 deletions packages/vitest/src/runners.ts
@@ -1,2 +1,3 @@
export { VitestTestRunner } from './runtime/runners/test'
export { NodeBenchmarkRunner } from './runtime/runners/benchmark'
export { VitestTestRunnerWithAsyncLeaksDetecter } from './runtime/runners/with-async-leaks-detecter'
9 changes: 8 additions & 1 deletion packages/vitest/src/runtime/runners/index.ts
Expand Up @@ -12,7 +12,14 @@ const runnersFile = resolve(distDir, 'runners.js')

async function getTestRunnerConstructor(config: ResolvedConfig, executor: VitestExecutor): Promise<VitestRunnerConstructor> {
if (!config.runner) {
const { VitestTestRunner, NodeBenchmarkRunner } = await executor.executeFile(runnersFile)
const { VitestTestRunner, NodeBenchmarkRunner, VitestTestRunnerWithAsyncLeaksDetecter } = await executor.executeFile(runnersFile)

if (config.detectAsyncLeaks) {
if (config.browser?.enabled)
throw new Error('Async leaks detection is not supported in browser mode.')
return VitestTestRunnerWithAsyncLeaksDetecter as VitestRunnerConstructor
}

return (config.mode === 'test' ? VitestTestRunner : NodeBenchmarkRunner) as VitestRunnerConstructor
}
const mod = await executor.executeId(config.runner)
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/runtime/runners/test.ts
Expand Up @@ -11,7 +11,7 @@ import { rpc } from '../rpc'

export class VitestTestRunner implements VitestRunner {
private snapshotClient = getSnapshotClient()
private workerState = getWorkerState()
protected workerState = getWorkerState()
private __vitest_executor!: VitestExecutor
private cancelRun = false

Expand Down
49 changes: 49 additions & 0 deletions packages/vitest/src/runtime/runners/with-async-leaks-detecter.ts
@@ -0,0 +1,49 @@
import asyncHooks from 'node:async_hooks'
import { relative } from 'pathe'
import { rpc } from '../rpc'
import { VitestTestRunner } from './test'

export interface HangingOps {
type: string
stack: string
taskId?: string
}

export class VitestTestRunnerWithAsyncLeaksDetecter extends VitestTestRunner {
private hangingOps: Map<number, HangingOps> = new Map()

private asyncHook: asyncHooks.AsyncHook = asyncHooks.createHook({
init: (id, type) => {
const task = this.workerState.current
const filepath = task?.file?.filepath || this.workerState.filepath

let stack = new Error('STACK_TRACE_ERROR').stack

if (filepath && stack?.includes(filepath)) {
stack = stack.split(/\n\s+/).findLast(s => s.includes(filepath))

if (stack) {
const hangingOp = {
type,
stack,
taskId: task?.id || relative(this.config.root, filepath),
}

this.hangingOps.set(id, hangingOp)
}
}
},
destroy: id => this.hangingOps.delete(id),
})

onBeforeRunFiles() {
super.onBeforeRunFiles()
this.asyncHook.enable()
}

onAfterRunFiles() {
rpc().detectAsyncLeaks(Array.from(this.hangingOps.values()))
this.asyncHook.disable()
super.onAfterRunFiles()
}
}
6 changes: 6 additions & 0 deletions packages/vitest/src/types/config.ts
Expand Up @@ -824,6 +824,12 @@ export interface UserConfig extends InlineConfig {
* Override vite config's clearScreen from cli
*/
clearScreen?: boolean

/**
* Detect async leaks in the test suite. This will slow down the test suite, use only in node environment.
* @default false
*/
detectAsyncLeaks?: boolean
}

export interface ResolvedConfig extends Omit<Required<UserConfig>, 'config' | 'filters' | 'browser' | 'coverage' | 'testNamePattern' | 'related' | 'api' | 'reporters' | 'resolveSnapshotPath' | 'benchmark' | 'shard' | 'cache' | 'sequence' | 'typecheck' | 'runner' | 'poolOptions' | 'pool' | 'cliExclude'> {
Expand Down
3 changes: 3 additions & 0 deletions packages/vitest/src/types/rpc.ts
@@ -1,5 +1,6 @@
import type { FetchResult, RawSourceMap, ViteNodeResolveId } from 'vite-node'
import type { CancelReason } from '@vitest/runner'
import type { HangingOps } from '../runtime/runners/with-async-leaks-detecter'
import type { EnvironmentOptions, Pool, ResolvedConfig, VitestEnvironment } from './config'
import type { Environment, UserConsoleLog } from './general'
import type { SnapshotResult } from './snapshot'
Expand All @@ -26,6 +27,8 @@ export interface RuntimeRPC {

snapshotSaved: (snapshot: SnapshotResult) => void
resolveSnapshotPath: (testPath: string) => string

detectAsyncLeaks: (ops: HangingOps[]) => void
}

export interface RunnerRPC {
Expand Down
21 changes: 21 additions & 0 deletions test/cli/fixtures/detect-async-leaks/promise.test.ts
@@ -0,0 +1,21 @@
import { afterAll, beforeAll, suite, test } from "vitest";

beforeAll(() => {
new Promise(() => {})
})

afterAll(() => {
new Promise(() => {})
})

suite('suite 1', () => {
test('hanging ops 1', () => {
new Promise(() => {})
})

suite('suite 2', () => {
test('hanging ops 2', () => {
new Promise(() => {})
})
})
})
21 changes: 21 additions & 0 deletions test/cli/fixtures/detect-async-leaks/setInterval.test.ts
@@ -0,0 +1,21 @@
import { afterAll, beforeAll, suite, test } from "vitest";

beforeAll(() => {
setInterval(() => {}, 1000)
})

afterAll(() => {
setInterval(() => {}, 1000)
})

suite('suite 1', () => {
test('hanging ops 1', () => {
setInterval(() => {}, 1000)
})

suite('suite 2', () => {
test('hanging ops 2', () => {
setInterval(() => {}, 1000)
})
})
})
21 changes: 21 additions & 0 deletions test/cli/fixtures/detect-async-leaks/timeout.test.ts
@@ -0,0 +1,21 @@
import { afterAll, beforeAll, suite, test } from "vitest";

beforeAll(() => {
setTimeout(() => {}, 1000)
})

afterAll(() => {
setTimeout(() => {}, 1000)
})

suite('suite 1', () => {
test('hanging ops 1', () => {
setTimeout(() => {}, 1000)
})

suite('suite 2', () => {
test('hanging ops 2', () => {
setTimeout(() => {}, 1000)
})
})
})
3 changes: 3 additions & 0 deletions test/cli/fixtures/detect-async-leaks/vitest.config.ts
@@ -0,0 +1,3 @@
import { defineConfig } from 'vitest/config'

export default defineConfig({})
67 changes: 67 additions & 0 deletions test/cli/test/detect-async-leaks.test.ts
@@ -0,0 +1,67 @@
import { expect, test } from 'vitest'
import { runVitestCli } from '../../test-utils'

test('should detect hanging operations', async () => {
let { stdout, stderr } = await runVitestCli(
'run',
'--root',
'fixtures/detect-async-leaks',
'--detectAsyncLeaks',
)

expect(stderr).toBeFalsy()
expect(stdout).toBeTruthy()
expect(stdout).contain('⎯⎯⎯⎯⎯ Hanging Operations ⎯⎯⎯⎯⎯')
// expect(stdout).contain('Vitest has detected the following 12 hanging operations potentially keeping Vitest from exiting:')

stdout = stdout.replaceAll(process.cwd(), '')

const intervals = [
`Timeout | setInterval.test.ts`,
`at /fixtures/detect-async-leaks/setInterval.test.ts:4:3`,

`Timeout | setInterval.test.ts > suite 1 > suite 2 > hanging ops 2`,
`at /fixtures/detect-async-leaks/setInterval.test.ts:18:7`,

`Timeout | setInterval.test.ts > suite 1 > hanging ops 1`,
`at /fixtures/detect-async-leaks/setInterval.test.ts:13:5`,

`Timeout | setInterval.test.ts`,
`at /fixtures/detect-async-leaks/setInterval.test.ts:8:3`,
]

intervals.forEach(interval => expect(stdout).toContain(interval))

const timeouts = [
`Timeout | timeout.test.ts`,
`at /fixtures/detect-async-leaks/timeout.test.ts:4:3`,

`Timeout | timeout.test.ts > suite 1 > suite 2 > hanging ops 2`,
`at /fixtures/detect-async-leaks/timeout.test.ts:18:7`,

`Timeout | timeout.test.ts > suite 1 > hanging ops 1`,
`at /fixtures/detect-async-leaks/timeout.test.ts:13:5`,

`Timeout | timeout.test.ts`,
`at /fixtures/detect-async-leaks/timeout.test.ts:8:3`,
]

timeouts.forEach(timeout => expect(stdout).toContain(timeout))

// promise test is not stable
// const promises = [
// `PROMISE | promise.test.ts
// at /fixtures/detect-async-leaks/promise.test.ts:4:3`,

// `PROMISE | promise.test.ts > suite 1 > suite 2 > hanging ops 2
// at /fixtures/detect-async-leaks/promise.test.ts:18:7`,

// `PROMISE | promise.test.ts > suite 1 > hanging ops 1
// at /fixtures/detect-async-leaks/promise.test.ts:13:5`,

// `PROMISE | promise.test.ts
// at /fixtures/detect-async-leaks/promise.test.ts:8:3`,
// ]

// promises.forEach(promise => expect(stdout).toContain(promise))
})
6 changes: 6 additions & 0 deletions test/core/test/cli-test.test.ts
Expand Up @@ -292,6 +292,12 @@ test('clearScreen', async () => {
`)
})

test('detectAsyncLeaks', async () => {
expect(getCLIOptions('--detectAsyncLeaks')).toEqual({ detectAsyncLeaks: true })
expect(getCLIOptions('--detectAsyncLeaks=true')).toEqual({ detectAsyncLeaks: true })
expect(getCLIOptions('--detectAsyncLeaks=false')).toEqual({ detectAsyncLeaks: false })
})

test('public parseCLI works correctly', () => {
expect(parseCLI('vitest dev')).toEqual({
filter: [],
Expand Down