diff --git a/packages/vitest/rollup.config.js b/packages/vitest/rollup.config.js index c5542c49a153..019c4d559a4e 100644 --- a/packages/vitest/rollup.config.js +++ b/packages/vitest/rollup.config.js @@ -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', diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index b5e16b9f526c..78a3b700cb0b 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -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, diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 18146a0c1e4f..a252cf05275c 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -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' @@ -69,6 +70,8 @@ export class Vitest { public distPath!: string + public hangingOps?: HangingOps[] + constructor( public readonly mode: VitestRunMode, options: VitestOptions = {}, @@ -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 = [] @@ -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 { @@ -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 diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index a494c9b619ee..0a88c5bc7a0b 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -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' @@ -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`, + )) + 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())) + } } diff --git a/packages/vitest/src/node/pools/rpc.ts b/packages/vitest/src/node/pools/rpc.ts index 3144e3c73f42..5564466d8bda 100644 --- a/packages/vitest/src/node/pools/rpc.ts +++ b/packages/vitest/src/node/pools/rpc.ts @@ -60,5 +60,8 @@ export function createMethodsRPC(project: WorkspaceProject): RuntimeRPC { getCountOfFailedTests() { return ctx.state.getCountOfFailedTests() }, + detectAsyncLeaks(hangingOps) { + ctx.hangingOps?.push(...hangingOps) + }, } } diff --git a/packages/vitest/src/runtime/runners/index.ts b/packages/vitest/src/runtime/runners/index.ts index 40197e90bc41..ac97f516d29c 100644 --- a/packages/vitest/src/runtime/runners/index.ts +++ b/packages/vitest/src/runtime/runners/index.ts @@ -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 { 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) diff --git a/packages/vitest/src/runtime/runners/node/detect-async-leaks.ts b/packages/vitest/src/runtime/runners/node/detect-async-leaks.ts new file mode 100644 index 000000000000..e0710448ae26 --- /dev/null +++ b/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 = 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() + } +} diff --git a/packages/vitest/src/runtime/runners/node/index.ts b/packages/vitest/src/runtime/runners/node/index.ts new file mode 100644 index 000000000000..b476898cbaa7 --- /dev/null +++ b/packages/vitest/src/runtime/runners/node/index.ts @@ -0,0 +1 @@ +export { WithAsyncLeaksDetecter } from './detect-async-leaks' diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts index e567506e75fc..c734a5677a45 100644 --- a/packages/vitest/src/runtime/runners/test.ts +++ b/packages/vitest/src/runtime/runners/test.ts @@ -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 diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index f75c660558c1..c250f45431e3 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -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, 'config' | 'filters' | 'browser' | 'coverage' | 'testNamePattern' | 'related' | 'api' | 'reporters' | 'resolveSnapshotPath' | 'benchmark' | 'shard' | 'cache' | 'sequence' | 'typecheck' | 'runner' | 'poolOptions' | 'pool' | 'cliExclude'> { diff --git a/packages/vitest/src/types/rpc.ts b/packages/vitest/src/types/rpc.ts index 3209c4a09695..4c112f3c029e 100644 --- a/packages/vitest/src/types/rpc.ts +++ b/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' @@ -26,6 +27,8 @@ export interface RuntimeRPC { snapshotSaved: (snapshot: SnapshotResult) => void resolveSnapshotPath: (testPath: string) => string + + detectAsyncLeaks: (ops: HangingOps[]) => void } export interface RunnerRPC { diff --git a/test/cli/fixtures/detect-async-leaks/edge-cases.test.ts b/test/cli/fixtures/detect-async-leaks/edge-cases.test.ts new file mode 100644 index 000000000000..f202635d2a40 --- /dev/null +++ b/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() + }) +}) diff --git a/test/cli/fixtures/detect-async-leaks/timeout.test.ts b/test/cli/fixtures/detect-async-leaks/timeout.test.ts new file mode 100644 index 000000000000..ba705a675a5a --- /dev/null +++ b/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) + }) + }) +}) diff --git a/test/cli/fixtures/detect-async-leaks/vitest.config.ts b/test/cli/fixtures/detect-async-leaks/vitest.config.ts new file mode 100644 index 000000000000..4a4f5cfe5563 --- /dev/null +++ b/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() + } +}) diff --git a/test/cli/test/__snapshots__/detect-async-leaks.test.ts.snap b/test/cli/test/__snapshots__/detect-async-leaks.test.ts.snap new file mode 100644 index 000000000000..ee87d7f727c1 --- /dev/null +++ b/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 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ +" +`; diff --git a/test/cli/test/detect-async-leaks.test.ts b/test/cli/test/detect-async-leaks.test.ts new file mode 100644 index 000000000000..1eaa4f650fc2 --- /dev/null +++ b/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() +}) diff --git a/test/core/test/cli-test.test.ts b/test/core/test/cli-test.test.ts index 1d4a3ce636cf..ae9ae27ef195 100644 --- a/test/core/test/cli-test.test.ts +++ b/test/core/test/cli-test.test.ts @@ -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: [],