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 all 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
1 change: 1 addition & 0 deletions packages/vitest/rollup.config.js
Expand Up @@ -24,6 +24,7 @@ const entries = {
'suite': 'src/suite.ts',
'browser': 'src/browser.ts',
'runners': 'src/runners.ts',
'runners-node': 'src/runtime/runners/node/index.ts',
'environments': 'src/environments.ts',
'spy': 'src/integrations/spy.ts',
'coverage': 'src/coverage.ts',
Expand Down
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/node/detect-async-leaks'
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/node/detect-async-leaks'
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(({ error, taskId }) => {
const task = taskId && this.ctx.state.idMap.get(taskId)
this.log(error.message + c.dim(` | ${task ? getFullName(task) : taskId}`))
this.log(`${c.gray(c.dim(`${error.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)
},
}
}
13 changes: 11 additions & 2 deletions packages/vitest/src/runtime/runners/index.ts
Expand Up @@ -8,11 +8,20 @@ import { rpc } from '../rpc'
import { takeCoverageInsideWorker } from '../../integrations/coverage'
import { loadDiffConfig, loadSnapshotSerializers } from '../setup-common'

const runnersFile = resolve(distDir, 'runners.js')
const commonRunnersFile = resolve(distDir, 'runners.js')
const nodeRunnersFile = resolve(distDir, 'runners-node.js')

async function getTestRunnerConstructor(config: ResolvedConfig, executor: VitestExecutor): Promise<VitestRunnerConstructor> {
if (!config.runner) {
const { VitestTestRunner, NodeBenchmarkRunner } = await executor.executeFile(runnersFile)
if (config.detectAsyncLeaks) {
if (config.browser?.enabled)
throw new Error('"--detectAsyncLeaks" flag is not supported in browser mode.')
const { WithAsyncLeaksDetecter } = await executor.executeFile(nodeRunnersFile)
return WithAsyncLeaksDetecter as VitestRunnerConstructor
}

const { VitestTestRunner, NodeBenchmarkRunner } = await executor.executeFile(commonRunnersFile)

return (config.mode === 'test' ? VitestTestRunner : NodeBenchmarkRunner) as VitestRunnerConstructor
}
const mod = await executor.executeId(config.runner)
Expand Down
93 changes: 93 additions & 0 deletions packages/vitest/src/runtime/runners/node/detect-async-leaks.ts
@@ -0,0 +1,93 @@
import asyncHooks from 'node:async_hooks'
import { promisify } from 'node:util'
import { relative } from 'pathe'
import { rpc } from '../../rpc'
import { VitestTestRunner } from '../test'

export interface HangingOps {
error: Error
taskId?: string
}

const asyncSleep = promisify(setTimeout)

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

private asyncHook: asyncHooks.AsyncHook = asyncHooks.createHook({
init: (asyncId, type, triggerAsyncId) => {
// Ignore some async resources
if (
[
'PROMISE',
'TIMERWRAP',
'ELDHISTOGRAM',
'PerformanceObserver',
'RANDOMBYTESREQUEST',
'DNSCHANNEL',
'ZLIB',
'SIGNREQUEST',
'TLSWRAP',
'TCPWRAP',
].includes(type)
)
return

const task = this.workerState.current
const filepath = task?.file?.filepath || this.workerState.filepath
if (!filepath)
return

const { stackTraceLimit } = Error
Error.stackTraceLimit = Math.max(100, stackTraceLimit)
const error = new Error(type)

let fromUser = error.stack?.includes(filepath)
let directlyTriggered = true

if (!fromUser) {
// Check if the async resource is indirectly triggered by user code
const trigger = this.hangingOps.get(triggerAsyncId)
if (trigger) {
fromUser = true
directlyTriggered = false
error.stack = trigger.error.stack
}
}

if (fromUser) {
const relativePath = relative(this.config.root, filepath)
if (directlyTriggered) {
error.stack = error.stack
?.split(/\n\s+/)
.findLast(s => s.includes(filepath))
?.replace(filepath, relativePath)
}

this.hangingOps.set(asyncId, {
error,
taskId: task?.id || relativePath,
})
}
},
destroy: (asyncId) => {
this.hangingOps.delete(asyncId)
},
})

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

async onAfterRunFiles() {
// Wait for async resources to be destroyed
await asyncSleep(0)
if (this.hangingOps.size > 0)
await asyncSleep(0)

rpc().detectAsyncLeaks(Array.from(this.hangingOps.values()))
this.asyncHook.disable()
super.onAfterRunFiles()
}
}
1 change: 1 addition & 0 deletions packages/vitest/src/runtime/runners/node/index.ts
@@ -0,0 +1 @@
export { WithAsyncLeaksDetecter } from './detect-async-leaks'
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
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/node/detect-async-leaks'
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
25 changes: 25 additions & 0 deletions test/cli/fixtures/detect-async-leaks/edge-cases.test.ts
@@ -0,0 +1,25 @@
import http from 'node:http'
import { TLSSocket } from 'node:tls'
import { suite, test } from 'vitest'

suite('should collect', () => {
test.todo('handles indirectly triggered by user code', async () => {
// const server = new http.Server()
// await new Promise(r => server.listen({ host: 'localhost', port: 0 }, r))
// await new Promise(r => server.close(r))
})
})

suite('should not collect', () => {
test('handles that have been queued to close', async () => {
const server = http.createServer((_, response) => response.end('ok'))
// @ts-expect-error let me go
await new Promise(r => server.listen(0, r))
await new Promise(r => server.close(r))
})
test('some special objects such as `TLSWRAP`', async () => {
// @ts-expect-error let me go
const socket = new TLSSocket()
socket.destroy()
})
})
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('test 1', () => {
setInterval(() => {}, 1000)
})

suite('suite 2', () => {
test('test 2', () => {
setInterval(() => {}, 1000)
})
})
})
13 changes: 13 additions & 0 deletions test/cli/fixtures/detect-async-leaks/vitest.config.ts
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
import { BaseReporter } from '../../../../packages/vitest/src/node/reporters/base'

class MyReporter extends BaseReporter {
onInit(): void {}
async onFinished() {}
}

export default defineConfig({
test: {
reporters: new MyReporter()
}
})
24 changes: 24 additions & 0 deletions test/cli/test/__snapshots__/detect-async-leaks.test.ts.snap
@@ -0,0 +1,24 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`should detect hanging operations - fixtures/detect-async-leaks/edge-cases.test.ts 1`] = `""`;

exports[`should detect hanging operations - fixtures/detect-async-leaks/timeout.test.ts 1`] = `
"⎯⎯⎯⎯⎯ Hanging Operations ⎯⎯⎯⎯⎯

Vitest has detected the following 4 hanging operations potentially keeping Vitest from exiting:

Timeout | timeout.test.ts
at timeout.test.ts:4:3

Timeout | timeout.test.ts > suite 1 > test 1
at timeout.test.ts:13:5

Timeout | timeout.test.ts > suite 1 > suite 2 > test 2
at timeout.test.ts:18:7

Timeout | timeout.test.ts
at timeout.test.ts:8:3

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
"
`;
17 changes: 17 additions & 0 deletions test/cli/test/detect-async-leaks.test.ts
@@ -0,0 +1,17 @@
import { expect, test } from 'vitest'
import { glob } from 'fast-glob'
import { runVitestCli } from '../../test-utils'

const files = glob.sync('fixtures/detect-async-leaks/*.test.ts')

test.each(files)('should detect hanging operations - %s', async (file) => {
const { stdout } = await runVitestCli(
'run',
'--root',
'fixtures/detect-async-leaks',
'--detectAsyncLeaks',
file,
)

expect(stdout).toMatchSnapshot()
})
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